movie list and portifolie died link
@@ -55,7 +55,6 @@ func main() {
|
|||||||
http.HandleFunc("/cinema", Cinema)
|
http.HandleFunc("/cinema", Cinema)
|
||||||
http.HandleFunc("/engine", Engine)
|
http.HandleFunc("/engine", Engine)
|
||||||
http.HandleFunc("/portfolio", Portfolio)
|
http.HandleFunc("/portfolio", Portfolio)
|
||||||
|
|
||||||
http.HandleFunc("/git", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/git", func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, "https://git.samantha42.xyz", http.StatusFound)
|
http.Redirect(w, r, "https://git.samantha42.xyz", http.StatusFound)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -93,6 +93,15 @@
|
|||||||
<div class="edu-inst">Aalborg University · Expected 2028</div>
|
<div class="edu-inst">Aalborg University · Expected 2028</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="edu-card">
|
||||||
|
<div class="edu-year">2024 — 2025</div>
|
||||||
|
<div class="edu-body">
|
||||||
|
<div class="edu-degree">Mathematics-Technology & Computer Science (BSc)</div>
|
||||||
|
<div class="edu-inst">Aalborg University · 1 semester</div>
|
||||||
|
<span class="edu-tag tag-dropped">discontinued</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="edu-card">
|
<div class="edu-card">
|
||||||
<div class="edu-year">2020 —<br>2023</div>
|
<div class="edu-year">2020 —<br>2023</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -136,6 +145,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-head"><h2>Movie/Show Top list</h2></div>
|
||||||
|
|
||||||
|
<div class="toplist-group">
|
||||||
|
<button class="toplist-toggle" onclick="this.closest('.toplist-group').classList.toggle('collapsed')">
|
||||||
|
Top 5 Movies <span class="toplist-toggle-icon">▾</span>
|
||||||
|
</button>
|
||||||
|
<div class="toplist-body">
|
||||||
|
<ol class="toplist-ranked">
|
||||||
|
<li>
|
||||||
|
Blade Runner 2049
|
||||||
|
<img src="/static/posters/bladerunner2049.jpg">
|
||||||
|
</li>
|
||||||
|
<li>A Cure of Wellness
|
||||||
|
<img src="/static/posters/AcureForWellness.jpg">
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>Fight Club
|
||||||
|
<img src="/static/posters/Fightclub.jpg">
|
||||||
|
</li>
|
||||||
|
<li>Dracula: A Love Tale
|
||||||
|
<img src="/static/posters/Dracula.webp">
|
||||||
|
</li>
|
||||||
|
<li>Girl with a Dragon Tattoo
|
||||||
|
<img src="/static/posters/TheGirlwiththeDragonTattoo.webp">
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toplist-group">
|
||||||
|
<button class="toplist-toggle" onclick="this.closest('.toplist-group').classList.toggle('collapsed')">
|
||||||
|
Top 5 Shows <span class="toplist-toggle-icon">▾</span>
|
||||||
|
</button>
|
||||||
|
<div class="toplist-body">
|
||||||
|
<ol class="toplist-ranked">
|
||||||
|
<li>Chernobyl
|
||||||
|
<img src="/static/posters/Chernobyl.webp">
|
||||||
|
</li>
|
||||||
|
<li>Succession
|
||||||
|
<img src="/static/posters/Succession.webp">
|
||||||
|
</li>
|
||||||
|
<li>Breaking Bad
|
||||||
|
<img src="/static/posters/Breaking Bad.webp">
|
||||||
|
</li>
|
||||||
|
<li>Avatar: The Last Airbender
|
||||||
|
<img src="/static/posters/Avatar.webp">
|
||||||
|
</li>
|
||||||
|
<li>Adolescence
|
||||||
|
<img src="/static/posters/Adolescence.webp">
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
= Top 5 Movies
|
||||||
|
1. Blade Runner 2049
|
||||||
|
2. A cure of Wellness
|
||||||
|
3. Fight Club
|
||||||
|
4. Dracula: A Love Tale
|
||||||
|
5. Girl with a Dragon Tattoo
|
||||||
|
|
||||||
|
|
||||||
|
= Movies
|
||||||
|
- Tenet
|
||||||
|
- The Outrun
|
||||||
|
- Hail Mary
|
||||||
|
- Day Break
|
||||||
|
- l, Tonya (2018)
|
||||||
|
- Caddo lake (2024)
|
||||||
|
- The Shawshank Redemption
|
||||||
|
- Fight Club
|
||||||
|
- Perl
|
||||||
|
- A Cure of Wellness, (2016)
|
||||||
|
- green book
|
||||||
|
- spirited away
|
||||||
|
- Saltburn
|
||||||
|
- Dracula: A Love Tale (2025)
|
||||||
|
- Frankenstein (2015)
|
||||||
|
- The Fall Guy
|
||||||
|
- The Notebook
|
||||||
|
- Primal Fear
|
||||||
|
- blade runner 2049
|
||||||
|
- girl with a dragon tatto
|
||||||
|
- Wicked
|
||||||
|
- Matrix
|
||||||
|
- Book of Eli
|
||||||
|
- Pulp Fiction
|
||||||
|
- Midsommer
|
||||||
|
- Living
|
||||||
|
- Moneyball
|
||||||
|
- Legally Blonde (2001)
|
||||||
|
- Dune
|
||||||
|
- Internal sunshine
|
||||||
|
|
||||||
|
|
||||||
|
== Danish
|
||||||
|
- A royal affiar
|
||||||
|
- Adams æbler
|
||||||
|
|
||||||
|
= Top 5 Shows
|
||||||
|
1. Chernobyl
|
||||||
|
2. Succesion
|
||||||
|
3. Breaking Bad
|
||||||
|
4. Avatar, the last air bender
|
||||||
|
5. Adolescence
|
||||||
|
|
||||||
|
= Shows
|
||||||
|
- succesion
|
||||||
|
- Breaking bad
|
||||||
|
- Adolescence (2025)
|
||||||
|
- The penguin
|
||||||
|
- Community
|
||||||
|
- Avatar, the last air bender
|
||||||
|
- Foundation
|
||||||
|
- Lessons in Chemistry
|
||||||
|
- Hanna
|
||||||
|
- You
|
||||||
|
- killing eve
|
||||||
|
- ozark
|
||||||
|
- Chernobyl
|
||||||
|
- The wire
|
||||||
|
|
||||||
|
|
||||||
@@ -4,8 +4,147 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Portfolio — Samantha Friis</title>
|
<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 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="styles.css" />
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0c0c0e;
|
||||||
|
--surface: #111114;
|
||||||
|
--surface2:#17171b;
|
||||||
|
--border: #232329;
|
||||||
|
--border2: #2e2e36;
|
||||||
|
--text: #e8e6e0;
|
||||||
|
--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; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -59,6 +198,40 @@
|
|||||||
<button class="refresh-btn" onclick="fetchHoldingPrices()">↺ refresh</button>
|
<button class="refresh-btn" onclick="fetchHoldingPrices()">↺ refresh</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Backend health status bar -->
|
||||||
|
<div class="status-bar">
|
||||||
|
<span class="status-dot loading" id="statusDot"></span>
|
||||||
|
<span id="statusText">connecting to backend…</span>
|
||||||
|
<span class="status-sep">·</span>
|
||||||
|
<span id="statusUptime" style="color:var(--muted)">uptime —</span>
|
||||||
|
<span class="status-sep">·</span>
|
||||||
|
<span id="statusDb" style="color:var(--muted)">db —</span>
|
||||||
|
<span class="status-spacer"></span>
|
||||||
|
<button class="refresh-btn" onclick="loadAll()">↺ refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary cards — populated from API -->
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-label">Positions</p>
|
||||||
|
<p class="card-value dim" id="cardPositions">…</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-label">Total Shares</p>
|
||||||
|
<p class="card-value dim" id="cardShares">…</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-label">Total Trades</p>
|
||||||
|
<p class="card-value dim" id="cardTrades">…</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-label">Currencies</p>
|
||||||
|
<p class="card-value dim" id="cardCurrencies">…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Positions — GET /positions/list -->
|
||||||
|
<p class="section-label">// <span>positions</span></p>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -75,11 +248,35 @@
|
|||||||
<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: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: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>
|
<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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="positionsBody">
|
||||||
|
<tr class="state-row"><td colspan="5">loading…</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Trades accordion -->
|
<!-- Trades accordion -->
|
||||||
|
<!-- Companies — GET /company/list -->
|
||||||
|
<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 id="companiesBody">
|
||||||
|
<tr class="state-row"><td colspan="5">loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trades — GET /trade/list — accordion -->
|
||||||
<div class="trades-section">
|
<div class="trades-section">
|
||||||
<button class="trades-toggle" aria-expanded="false" onclick="toggleTrades(this)">
|
<button class="trades-toggle" aria-expanded="false" onclick="toggleTrades(this)">
|
||||||
<span class="toggle-label">// <span>trade history</span> — all executed orders</span>
|
<span class="toggle-label">// <span>trade history</span> — all executed orders</span>
|
||||||
@@ -97,6 +294,11 @@
|
|||||||
<button class="filter-btn" onclick="filterTrades('sell', this)">Sell</button>
|
<button class="filter-btn" onclick="filterTrades('sell', this)">Sell</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="add-trade-btn" onclick="openAddTrade()">+ add trade</button>
|
<button class="add-trade-btn" onclick="openAddTrade()">+ add trade</button>
|
||||||
|
<div class="trades-body" id="tradesAccordion">
|
||||||
|
<div class="trades-inner">
|
||||||
|
|
||||||
|
<div class="trades-filter" id="tradeFilters">
|
||||||
|
<button class="filter-btn active" onclick="filterTrades('all', this)">All</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="trades-count" id="tradesCount">—</p>
|
<p class="trades-count" id="tradesCount">—</p>
|
||||||
@@ -108,6 +310,7 @@
|
|||||||
<th>Symbol</th>
|
<th>Symbol</th>
|
||||||
<th>Asset</th>
|
<th>Asset</th>
|
||||||
<th>Ccy</th>
|
<th>Ccy</th>
|
||||||
|
<th>Currency</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Dir</th>
|
<th>Dir</th>
|
||||||
<th>Qty</th>
|
<th>Qty</th>
|
||||||
@@ -116,15 +319,19 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="tradesBody_rows">
|
<tbody id="tradesBody_rows">
|
||||||
<tr><td colspan="7" class="trades-empty">open to load trades</td></tr>
|
<tr><td colspan="7" class="trades-empty">open to load trades</td></tr>
|
||||||
|
<tbody id="tradesBody">
|
||||||
|
<tr class="state-row"><td colspan="7">expand to load trades</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<a href="mailto:samantha@samantha42.xyz">samantha@samantha42.xyz</a>
|
<a href="mailto:me@smantha42.xyz">me@smantha42.xyz</a>
|
||||||
|
<a href="mailto:samantha@friis.dk">samantha@friis.dk</a>
|
||||||
<p class="footer-copy">© 2026 — All rights reserved</p>
|
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -189,309 +396,242 @@
|
|||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API = 'http://127.0.0.1:8080';
|
const API = 'http://localhost:8080';
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────
|
const PRODUCT_LABELS = { 0:'Stock', 1:'Call Option', 2:'Put Option', 3:'Currency', 4:'Bond' };
|
||||||
const PRODUCT_LABELS = ['Stock', 'Opt Call', 'Opt Put', 'Currency', 'Bond'];
|
|
||||||
function productLabel(n) { return PRODUCT_LABELS[n] ?? '—'; }
|
|
||||||
|
|
||||||
// ── GET /health ──────────────────────────
|
/* ── helpers ─────────────────────────────────────────────────────────── */
|
||||||
async function checkHealth() {
|
|
||||||
const dot = document.getElementById('healthDot');
|
function n(v, dec = 2) {
|
||||||
const text = document.getElementById('healthText');
|
if (v == null || isNaN(v)) return '—';
|
||||||
|
return Number(v).toLocaleString('da-DK', { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
return iso ? iso.slice(0, 10) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(path) {
|
||||||
|
const r = await fetch(API + path);
|
||||||
|
if (!r.ok) throw new Error(path + ' ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── health ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async function loadHealth() {
|
||||||
|
const dot = document.getElementById('statusDot');
|
||||||
|
const text = document.getElementById('statusText');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/health`);
|
const h = await get('/health');
|
||||||
const data = await res.json();
|
const ok = h.status === 'ok';
|
||||||
if (data.status === 'ok') {
|
dot.className = 'status-dot ' + (ok ? 'ok' : 'error');
|
||||||
dot.className = 'health-dot ok';
|
text.textContent = 'backend ' + h.status;
|
||||||
text.textContent = `api ok — uptime ${data.uptime ?? ''}`;
|
document.getElementById('statusUptime').textContent = 'up ' + (h.uptime || '—');
|
||||||
} else {
|
const db = h.database || {};
|
||||||
dot.className = 'health-dot error';
|
document.getElementById('statusDb').textContent =
|
||||||
text.textContent = 'api degraded';
|
'db ' + (db.status || '—') + (db.latency ? ' · ' + db.latency : '');
|
||||||
}
|
} catch (e) {
|
||||||
} catch {
|
dot.className = 'status-dot error';
|
||||||
dot.className = 'health-dot error';
|
text.textContent = 'backend unreachable';
|
||||||
text.textContent = 'api unreachable';
|
document.getElementById('statusUptime').textContent = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /positions/list ──────────────────
|
/* ── currencies ─────────────────────────────────────────────────────── */
|
||||||
let positions = [];
|
|
||||||
|
let ccyMap = {}; // id → code
|
||||||
|
|
||||||
|
async function loadCurrencies() {
|
||||||
|
try {
|
||||||
|
const list = await get('/currency/list');
|
||||||
|
const arr = Array.isArray(list) ? list : (list ? [list] : []);
|
||||||
|
arr.forEach(c => {
|
||||||
|
// field names may vary
|
||||||
|
const id = c.ID ?? c.id;
|
||||||
|
const code = c.Code ?? c.code ?? c.CodeName ?? '—';
|
||||||
|
if (id != null) ccyMap[id] = code;
|
||||||
|
});
|
||||||
|
set('cardCurrencies', arr.length);
|
||||||
|
} catch (_) {
|
||||||
|
set('cardCurrencies', 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── positions ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
async function loadPositions() {
|
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');
|
const tbody = document.getElementById('positionsBody');
|
||||||
if (!positions.length) {
|
try {
|
||||||
tbody.innerHTML = `<tr><td colspan="6" class="trades-empty">no positions found</td></tr>`;
|
const raw = await get('/positions/list');
|
||||||
|
const list = Array.isArray(raw) ? raw : (raw ? [raw] : []);
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
tbody.innerHTML = stateRow(5, 'no positions');
|
||||||
|
set('cardPositions', 0); set('cardShares', 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = positions.map(p => {
|
|
||||||
const sym = p.Symbol ?? '—';
|
const totalCost = list.reduce((s, p) => s + (p.CostBasis || 0), 0);
|
||||||
const ccy = p.CurrencyCode ?? '—';
|
const totalShares = list.reduce((s, p) => s + (p.Shares || 0), 0);
|
||||||
const sh = p.Shares ?? '—';
|
|
||||||
const cb = typeof p.CostBasis === 'number'
|
tbody.innerHTML = list.map(p => {
|
||||||
? p.CostBasis.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
const ccy = p.CurrencyCode || ccyMap[p.CurrencyID] || '—';
|
||||||
: '—';
|
const w = totalCost > 0 ? (p.CostBasis / totalCost * 100) : 0;
|
||||||
const wt = typeof p.Weight === 'number'
|
return `<tr>
|
||||||
? (p.Weight * 100).toFixed(2) + '%'
|
<td><span class="ticker">${p.Symbol || '—'}</span></td>
|
||||||
: '—';
|
<td><span class="currency-badge">${ccy}</span></td>
|
||||||
const pct = typeof p.Weight === 'number' ? Math.min(p.Weight * 100, 100) : 0;
|
<td>${n(p.Shares, 0)}</td>
|
||||||
return `<tr data-symbol="${sym}">
|
<td>${n(p.CostBasis)}</td>
|
||||||
<td><span class="ticker">${sym}</span><span class="currency-badge">${ccy}</span></td>
|
<td><div class="weight-cell">${w.toFixed(1)}%
|
||||||
<td>${ccy}</td>
|
<div class="weight-bar-track">
|
||||||
<td>${sh}</td>
|
<div class="weight-bar-fill" style="width:${Math.min(Math.round(w),100)}%"></div>
|
||||||
<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>
|
</div>
|
||||||
</td>
|
</div></td>
|
||||||
<td><span class="price-val" id="price-${sym}">—</span></td>
|
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
set('cardPositions', list.length);
|
||||||
|
set('cardShares', n(totalShares, 0));
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = stateRow(5, 'load failed: ' + e.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSummaryCards() {
|
/* ── companies ───────────────────────────────────────────────────────── */
|
||||||
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 ────────────────────────
|
async function loadCompanies() {
|
||||||
function setStatus(state, text) {
|
const tbody = document.getElementById('companiesBody');
|
||||||
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 {
|
try {
|
||||||
const res = await fetch('/api/prices', {
|
const raw = await get('/company/list');
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
const list = Array.isArray(raw) ? raw : (raw ? [raw] : []);
|
||||||
body: JSON.stringify({ tickers: positions.map(p => p.Symbol) }),
|
|
||||||
});
|
if (!list.length) {
|
||||||
if (!res.ok) throw new Error('backend ' + res.status);
|
tbody.innerHTML = stateRow(5, 'no companies');
|
||||||
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;
|
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 ───────────────────────
|
tbody.innerHTML = list.map(c => {
|
||||||
|
const ccy = ccyMap[c.CurrencyID] || '—';
|
||||||
|
const mcap = (c.SharesOutstanding && c.Price)
|
||||||
|
? n(c.SharesOutstanding * c.Price / 1_000_000, 0) + ' M'
|
||||||
|
: '—';
|
||||||
|
return `<tr>
|
||||||
|
<td><span class="ticker">${c.Symbol || '—'}</span></td>
|
||||||
|
<td><span class="currency-badge">${ccy}</span></td>
|
||||||
|
<td>${n(c.Price)}</td>
|
||||||
|
<td>${n(c.SharesOutstanding, 0)}</td>
|
||||||
|
<td>${mcap}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = stateRow(5, 'load failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── trades ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
let allTrades = [];
|
let allTrades = [];
|
||||||
let activeFilter = 'all';
|
let tradesLoaded = false;
|
||||||
|
|
||||||
async function loadTrades() {
|
async function loadTrades() {
|
||||||
document.getElementById('tradesCount').textContent = 'loading…';
|
const tbody = document.getElementById('tradesBody');
|
||||||
document.getElementById('tradesBody_rows').innerHTML =
|
const filters = document.getElementById('tradeFilters');
|
||||||
`<tr><td colspan="7" class="trades-empty">loading…</td></tr>`;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/trade/list`);
|
const raw = await get('/trade/list');
|
||||||
if (!res.ok) throw new Error('status ' + res.status);
|
allTrades = Array.isArray(raw) ? raw : (raw ? [raw] : []);
|
||||||
const data = await res.json();
|
tradesLoaded = true;
|
||||||
allTrades = Array.isArray(data) ? data : [];
|
|
||||||
} catch (err) {
|
// Build per-symbol filter buttons
|
||||||
console.warn('trade/list failed:', err);
|
const syms = [...new Set(allTrades.map(t => t.Symbol).filter(Boolean))];
|
||||||
allTrades = [];
|
syms.forEach(sym => {
|
||||||
|
if (!filters.querySelector(`[data-sym="${sym}"]`)) {
|
||||||
|
const btn = Object.assign(document.createElement('button'), {
|
||||||
|
className: 'filter-btn',
|
||||||
|
textContent: sym,
|
||||||
|
onclick() { filterTrades(sym, this); },
|
||||||
|
});
|
||||||
|
btn.dataset.sym = sym;
|
||||||
|
filters.appendChild(btn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
set('cardTrades', allTrades.length);
|
||||||
|
renderTrades('all');
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = stateRow(7, 'load failed: ' + e.message);
|
||||||
}
|
}
|
||||||
renderTrades();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTrades() {
|
function renderTrades(sym) {
|
||||||
const tbody = document.getElementById('tradesBody_rows');
|
const tbody = document.getElementById('tradesBody');
|
||||||
const countEl = document.getElementById('tradesCount');
|
const countEl = document.getElementById('tradesCount');
|
||||||
const filtered = allTrades.filter(t => {
|
const filtered = sym === 'all' ? allTrades : allTrades.filter(t => t.Symbol === sym);
|
||||||
if (activeFilter === 'all') return true;
|
|
||||||
if (activeFilter === 'stock') return t.Product === 0;
|
|
||||||
if (activeFilter === 'option') return t.Product === 1 || t.Product === 2;
|
|
||||||
if (activeFilter === 'buy') return t.Type === false;
|
|
||||||
if (activeFilter === 'sell') return t.Type === true;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (!filtered.length) {
|
if (!filtered.length) {
|
||||||
tbody.innerHTML = `<tr><td colspan="7" class="trades-empty">${
|
tbody.innerHTML = stateRow(7, 'no trades');
|
||||||
allTrades.length === 0 ? 'no trades found' : 'no trades match filter'
|
|
||||||
}</td></tr>`;
|
|
||||||
countEl.textContent = '0 trades';
|
countEl.textContent = '0 trades';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
countEl.textContent = filtered.length + ' trade' + (filtered.length !== 1 ? 's' : '')
|
|
||||||
+ (activeFilter !== 'all' ? ' — ' + activeFilter : '');
|
|
||||||
tbody.innerHTML = filtered.map(t => {
|
tbody.innerHTML = filtered.map(t => {
|
||||||
const sym = t.Symbol ?? '—';
|
const buy = t.Type === true || t.Type === 'true';
|
||||||
const ccy = t.CurrencyCode ?? '—';
|
const prod = PRODUCT_LABELS[t.Product] ?? ('type ' + t.Product);
|
||||||
const date = t.Date ? new Date(t.Date).toLocaleDateString('sv-SE') : '—';
|
|
||||||
const price = typeof t.Price === 'number'
|
|
||||||
? t.Price.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
||||||
: '—';
|
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td><span class="trade-ticker">${sym}</span></td>
|
<td><span class="trade-ticker">${t.Symbol || '—'}</span></td>
|
||||||
<td>${productLabel(t.Product)}</td>
|
<td>${prod}</td>
|
||||||
<td>${ccy}</td>
|
<td>${t.CurrencyCode || '—'}</td>
|
||||||
<td>${date}</td>
|
<td>${fmtDate(t.Date)}</td>
|
||||||
<td class="${t.Type ? 'dir-sell' : 'dir-buy'}">${t.Type ? 'SELL' : 'BUY'}</td>
|
<td class="${buy ? 'dir-buy' : 'dir-sell'}">${buy ? 'BUY' : 'SELL'}</td>
|
||||||
<td>${t.Shares ?? '—'}</td>
|
<td>${n(t.Shares, 0)}</td>
|
||||||
<td>${price}</td>
|
<td>${n(t.Price)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
countEl.textContent = filtered.length + ' trade' + (filtered.length !== 1 ? 's' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── accordion / filter ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function toggleTrades(btn) {
|
||||||
|
const body = document.getElementById('tradesAccordion');
|
||||||
|
const expanded = btn.getAttribute('aria-expanded') === 'true';
|
||||||
|
btn.setAttribute('aria-expanded', String(!expanded));
|
||||||
|
body.classList.toggle('open', !expanded);
|
||||||
|
if (!expanded && !tradesLoaded) loadTrades();
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterTrades(sym, btn) {
|
function filterTrades(sym, btn) {
|
||||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
activeFilter = sym;
|
renderTrades(sym);
|
||||||
renderTrades();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTrades(btn) {
|
/* ── misc helpers ────────────────────────────────────────────────────── */
|
||||||
const body = document.getElementById('tradesBody');
|
|
||||||
const expanded = btn.getAttribute('aria-expanded') === 'true';
|
function set(id, val) {
|
||||||
btn.setAttribute('aria-expanded', String(!expanded));
|
const el = document.getElementById(id);
|
||||||
body.classList.toggle('open', !expanded);
|
if (el) { el.textContent = val; el.classList.remove('dim'); }
|
||||||
if (!expanded && allTrades.length === 0) loadTrades();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /trade/add ───────────────────────
|
function stateRow(cols, msg) {
|
||||||
function openAddTrade() {
|
return `<tr class="state-row"><td colspan="${cols}">${msg}</td></tr>`;
|
||||||
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(); }
|
|
||||||
|
|
||||||
async function submitTrade() {
|
|
||||||
const errEl = document.getElementById('formError');
|
|
||||||
const btn = document.getElementById('submitBtn');
|
|
||||||
errEl.textContent = '';
|
|
||||||
|
|
||||||
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();
|
|
||||||
const product = parseInt(document.getElementById('f-product').value);
|
|
||||||
const type = document.getElementById('f-type').value === 'true';
|
|
||||||
const dateVal = document.getElementById('f-date').value;
|
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
// Payload matches POST /trade/add spec from wiki
|
|
||||||
const payload = {
|
|
||||||
symbol,
|
|
||||||
currency_code: currency,
|
|
||||||
shares,
|
|
||||||
product,
|
|
||||||
type,
|
|
||||||
price,
|
|
||||||
date: date.toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'submitting…';
|
|
||||||
try {
|
|
||||||
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();
|
|
||||||
} catch (err) {
|
|
||||||
errEl.textContent = err.message || 'failed to submit trade';
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'submit trade';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toast ─────────────────────────────────
|
/* ── boot ────────────────────────────────────────────────────────────── */
|
||||||
function showToast(msg, type = '') {
|
|
||||||
const el = document.getElementById('toast');
|
async function loadAll() {
|
||||||
el.textContent = msg;
|
// Reset status
|
||||||
el.className = 'toast ' + type + ' show';
|
document.getElementById('statusDot').className = 'status-dot loading';
|
||||||
clearTimeout(el._t);
|
document.getElementById('statusText').textContent = 'connecting…';
|
||||||
el._t = setTimeout(() => el.classList.remove('show'), 3000);
|
|
||||||
|
await loadHealth();
|
||||||
|
await loadCurrencies(); // currency map must be ready before tables
|
||||||
|
await Promise.all([loadPositions(), loadCompanies()]);
|
||||||
|
// Trades loaded lazily on accordion open
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────
|
document.addEventListener('DOMContentLoaded', loadAll);
|
||||||
(async () => {
|
|
||||||
await Promise.all([checkHealth(), loadPositions()]);
|
|
||||||
fetchHoldingPrices();
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
After Width: | Height: | Size: 710 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 929 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -1151,487 +1151,19 @@ nav .section-label {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* ─── Portfolio page ─── */
|
|
||||||
|
|
||||||
/* Health / API status badge */
|
.edu-tag {
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section header row (above table) */
|
|
||||||
.section-header-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Live price status indicator */
|
|
||||||
.price-cell {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.price-val {
|
|
||||||
transition: color 0.4s;
|
|
||||||
}
|
|
||||||
.price-val.loading {
|
|
||||||
color: var(--dim);
|
|
||||||
}
|
|
||||||
.price-val.flash-up {
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
.price-val.flash-down {
|
|
||||||
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);
|
|
||||||
margin-left: auto;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.price-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--dim);
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
flex-shrink: 0;
|
margin-top: 8px;
|
||||||
}
|
font-family: 'DM Mono', monospace;
|
||||||
.price-dot.live {
|
font-size: 10px;
|
||||||
background: var(--green);
|
letter-spacing: 0.1em;
|
||||||
box-shadow: 0 0 4px rgba(10, 124, 82, 0.6);
|
|
||||||
}
|
|
||||||
.price-dot.error {
|
|
||||||
background: var(--red);
|
|
||||||
}
|
|
||||||
.price-dot.loading {
|
|
||||||
background: var(--yellow);
|
|
||||||
animation: pulse 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.refresh-btn {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--ff-mono);
|
|
||||||
font-size: 0.68rem;
|
|
||||||
padding: 2px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.refresh-btn:hover {
|
|
||||||
border-color: var(--accent2);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
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;
|
text-transform: uppercase;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 3px 8px;
|
||||||
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;
|
border-radius: 3px;
|
||||||
font-size: 0.65rem;
|
}
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add Trade modal */
|
.tag-dropped {
|
||||||
.modal-overlay {
|
background: #F0EBE4;
|
||||||
display: none;
|
color: #8A5C2E;
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -229,3 +229,69 @@ footer{
|
|||||||
aside{display:none;}
|
aside{display:none;}
|
||||||
.edu-card{grid-template-columns:1fr;gap:0.3rem;}
|
.edu-card{grid-template-columns:1fr;gap:0.3rem;}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edu-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-family: 'DM Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-dropped {
|
||||||
|
background: #F0EBE4;
|
||||||
|
color: #8A5C2E;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.toplist-group{
|
||||||
|
display:grid;grid-template-columns:80px 1fr;gap:1.2rem;
|
||||||
|
background:var(--surface);border:1px solid var(--border);
|
||||||
|
border-radius:6px;padding:1.2rem 1.4rem;
|
||||||
|
transition:border-color 0.2s,box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toplist-body { display: none; }
|
||||||
|
.collapsed .toplist-body { display: block; }
|
||||||
|
.collapsed .toplist-toggle-icon { transform: rotate(-90deg); }
|
||||||
|
.toplist-toggle-icon { display: inline-block; transition: transform 0.2s; }
|
||||||
|
|
||||||
|
.toplist-ranked {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toplist-ranked li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
counter-increment: rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toplist-ranked {
|
||||||
|
counter-reset: rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toplist-ranked li::before {
|
||||||
|
content: counter(rank);
|
||||||
|
font-family: 'DM Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toplist-ranked li img {
|
||||||
|
width: 42px;
|
||||||
|
height: 62px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||