Files
personal-site/source/admin.html
T
2026-08-28 01:36:11 +02:00

516 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin · samantha42.xyz</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;1,300&display=swap" rel="stylesheet" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #fafaf8;
--bg-2: #f1efe8;
--text: #1a1a18;
--muted: #888780;
--border: rgba(0,0,0,0.12);
--accent: #185FA5;
--accent-dim: #E6F1FB;
--success: #0F6E56;
--success-dim: #E1F5EE;
--danger: #A32D2D;
--danger-dim: #FCEBEB;
--warn: #854F0B;
--warn-dim: #FAEEDA;
--ff-mono: 'IBM Plex Mono', monospace;
--ff-sans: 'IBM Plex Sans', sans-serif;
}
body.night {
--bg: #111110;
--bg-2: #1a1a18;
--text: #e8e6df;
--muted: #5F5E5A;
--border: rgba(255,255,255,0.1);
--accent-dim: #042C53;
}
body {
font-family: var(--ff-mono);
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 13px;
}
nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
height: 48px;
border-bottom: 0.5px solid var(--border);
}
.nav-logo { font-size: 13px; color: var(--text); text-decoration: none; }
.nav-right { display: flex; align-items: center; gap: 1.5rem; }
.nav-right a { font-size: 12px; color: var(--muted); text-decoration: none; }
.nav-right a:hover { color: var(--text); }
.shell { display: grid; grid-template-columns: 200px 1fr; min-height: calc(100vh - 48px); }
aside {
border-right: 0.5px solid var(--border);
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.sidebar-label {
font-size: 10px;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 8px;
}
.sidebar-links { display: flex; flex-direction: column; gap: 2px; }
.sidebar-links a {
font-size: 12px;
color: var(--muted);
text-decoration: none;
padding: 5px 8px;
border-radius: 4px;
transition: background 0.15s, color 0.15s;
}
.sidebar-links a:hover { background: var(--bg-2); color: var(--text); }
.sidebar-links a.active { background: var(--bg-2); color: var(--text); }
main { padding: 2rem; max-width: 900px; }
.page-header { margin-bottom: 2rem; }
.page-title { font-size: 20px; font-weight: 500; color: var(--text); }
.page-sub { font-size: 11px; color: var(--muted); letter-spacing: 1px; }
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg-2);
border-radius: 6px;
padding: 1rem;
}
.stat-label { font-size: 10px; color: var(--muted); letter-spacing: 1px; text-transform: uppercase; margin-bottom: 6px; }
.stat-value { font-size: 22px; font-weight: 500; color: var(--text); }
.stat-sub { font-size: 10px; color: var(--muted); margin-top: 2px; }
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 8px;
border-bottom: 0.5px solid var(--border);
}
.section-head h2 { font-size: 13px; font-weight: 500; color: var(--text); }
.job-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 2rem; }
.job-row {
display: grid;
grid-template-columns: 1fr 120px 100px 80px;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 0.5px solid var(--border);
border-radius: 6px;
background: var(--bg);
}
.job-name { font-size: 12px; color: var(--text); }
.job-time { font-size: 11px; color: var(--muted); }
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
font-family: var(--ff-mono);
letter-spacing: 0.5px;
}
.badge-ok { background: var(--success-dim); color: var(--success); }
.badge-fail { background: var(--danger-dim); color: var(--danger); }
.badge-run { background: var(--warn-dim); color: var(--warn); }
.badge-idle { background: var(--bg-2); color: var(--muted); }
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.run-btn {
font-family: var(--ff-mono);
font-size: 11px;
padding: 4px 10px;
border: 0.5px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.run-btn:hover { background: var(--bg-2); color: var(--text); }
.compose-card {
border: 0.5px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--bg);
margin-bottom: 2rem;
}
.compose-head {
background: #0C447C;
padding: 1rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.compose-head-title { font-size: 12px; color: #B5D4F4; letter-spacing: 1px; text-transform: uppercase; }
.compose-head-sub { font-size: 11px; color: #378ADD; }
.compose-body { padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem; }
.field-label {
font-size: 10px;
color: var(--muted);
letter-spacing: 1px;
text-transform: uppercase;
display: block;
margin-bottom: 5px;
}
.field-input, .field-select, .field-textarea {
width: 100%;
font-family: var(--ff-mono);
font-size: 13px;
padding: 9px 12px;
border: 0.5px solid var(--border);
border-radius: 5px;
background: var(--bg-2);
color: var(--text);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.field-input:focus, .field-select:focus, .field-textarea:focus {
border-color: #378ADD;
box-shadow: 0 0 0 3px rgba(55,138,221,0.12);
}
.field-textarea { resize: vertical; min-height: 200px; line-height: 1.6; }
.compose-footer {
padding: 1rem 1.25rem;
border-top: 0.5px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.sub-count { font-size: 11px; color: var(--muted); }
.btn-send {
font-family: var(--ff-mono);
font-size: 12px;
padding: 8px 20px;
background: #185FA5;
color: #E6F1FB;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
letter-spacing: 0.5px;
}
.btn-send:hover { background: #0C447C; }
.btn-send:active { transform: scale(0.98); }
.btn-preview {
font-family: var(--ff-mono);
font-size: 12px;
padding: 8px 16px;
background: transparent;
color: var(--muted);
border: 0.5px solid var(--border);
border-radius: 5px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-preview:hover { background: var(--bg-2); color: var(--text); }
.send-result {
font-size: 11px;
color: var(--success);
display: none;
margin-top: 4px;
}
.htmx-indicator {
opacity: 0;
font-size: 11px;
color: var(--muted);
transition: opacity 0.2s;
}
.htmx-request .htmx-indicator { opacity: 1; }
.section-gap { margin-bottom: 2.5rem; }
.ticker-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0.5px solid var(--border);
border-radius: 6px;
margin-bottom: 6px;
font-size: 12px;
}
.ticker-symbol { font-weight: 500; min-width: 80px; }
.ticker-rating-buy { color: var(--success); }
.ticker-rating-sell { color: var(--danger); }
.ticker-muted { color: var(--muted); flex: 1; }
@media (max-width: 640px) {
.shell { grid-template-columns: 1fr; }
aside { display: none; }
.job-row { grid-template-columns: 1fr 80px; }
.job-time, .run-btn { display: none; }
}
</style>
</head>
<body id="themeRoot">
<nav>
<a class="nav-logo" href="/">samantha_vero_friis</a>
<div class="nav-right">
<span style="font-size:11px;color:var(--muted)">admin portal</span>
<a href="/logout">logout →</a>
</div>
</nav>
<div class="shell">
<aside>
<div>
<p class="sidebar-label">admin</p>
<div class="sidebar-links">
<a href="#overview" class="active">overview</a>
<a href="#jobs">job status</a>
<a href="#newsletter">newsletter</a>
<a href="#research">eq research</a>
</div>
</div>
<div>
<p class="sidebar-label">site</p>
<div class="sidebar-links">
<a href="/" target="_blank">view site ↗</a>
<a href="/git" target="_blank">git repo ↗</a>
</div>
</div>
</aside>
<main>
<div class="page-header" id="overview">
<div class="page-title">admin</div>
<div class="page-sub">samantha42.xyz · control panel</div>
</div>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-label">subscribers</div>
<div class="stat-value" hx-get="/admin/api/stats/subscribers" hx-trigger="load" hx-swap="innerHTML"></div>
<div class="stat-sub">newsletter list</div>
</div>
<div class="stat-card">
<div class="stat-label">emails sent</div>
<div class="stat-value" hx-get="/admin/api/stats/sent" hx-trigger="load" hx-swap="innerHTML"></div>
<div class="stat-sub">all time</div>
</div>
<div class="stat-card">
<div class="stat-label">analyses</div>
<div class="stat-value">2</div>
<div class="stat-sub">published</div>
</div>
<div class="stat-card">
<div class="stat-label">jobs ok</div>
<div class="stat-value" hx-get="/admin/api/stats/jobs" hx-trigger="load" hx-swap="innerHTML"></div>
<div class="stat-sub">last 24h</div>
</div>
</div>
<div class="section-gap" id="jobs">
<div class="section-head">
<h2>// job status</h2>
<button class="run-btn" hx-get="/admin/api/jobs" hx-target="#job-list" hx-swap="innerHTML">refresh</button>
</div>
<div class="job-list" id="job-list"
hx-get="/admin/api/jobs"
hx-trigger="load"
hx-swap="innerHTML">
<div class="job-row">
<span class="job-name">portfolio-engine</span>
<span class="job-time">checking...</span>
<span class="badge badge-idle"><span class="dot"></span> loading</span>
<span></span>
</div>
</div>
</div>
<div class="section-gap" id="newsletter">
<div class="section-head">
<h2>// send newsletter</h2>
<span class="htmx-indicator" id="send-spinner">sending...</span>
</div>
<div class="compose-card">
<div class="compose-head">
<span class="compose-head-title">equity research · newsletter</span>
<span class="compose-head-sub">to all subscribers</span>
</div>
<div class="compose-body">
<div>
<label class="field-label" for="nl-subject">subject</label>
<input class="field-input" type="text" id="nl-subject" name="subject"
placeholder="MSTR: why the premium collapses without a bid" />
</div>
<div>
<label class="field-label" for="nl-ticker">ticker (optional)</label>
<input class="field-input" type="text" id="nl-ticker" name="ticker"
placeholder="MSTR · sell" style="width:200px;" />
</div>
<div>
<label class="field-label" for="nl-body">body</label>
<textarea class="field-textarea" id="nl-body" name="body"
placeholder="// thesis&#10;&#10;The truth I keep coming back to..."></textarea>
</div>
<div>
<label class="field-label" for="nl-pdf">attach pdf (path on server)</label>
<input class="field-input" type="text" id="nl-pdf" name="pdf_path"
placeholder="/static/pdf/mstr.pdf" style="width:300px;" />
</div>
</div>
<div class="compose-footer">
<span class="sub-count" hx-get="/admin/api/stats/subscribers" hx-trigger="load" hx-swap="innerHTML"
style="font-size:11px;color:var(--muted)">— subscribers</span>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn-preview" onclick="previewEmail()">preview</button>
<button class="btn-send"
hx-post="/admin/api/newsletter/send"
hx-include="#nl-subject,#nl-ticker,#nl-body,#nl-pdf"
hx-target="#send-result"
hx-swap="innerHTML"
hx-indicator="#send-spinner">
send to all →
</button>
</div>
</div>
<div id="send-result" style="padding:0 1.25rem 1rem;font-size:11px;"></div>
</div>
</div>
<div class="section-gap" id="research">
<div class="section-head">
<h2>// equity research</h2>
<a href="#newsletter" style="font-size:11px;color:var(--muted);text-decoration:none;">send as newsletter ↑</a>
</div>
<div class="ticker-row">
<span class="ticker-symbol">MSTR</span>
<span class="ticker-rating-sell">sell</span>
<span class="ticker-muted">Strategy · BTC proxy via debt issuance</span>
<a href="/static/pdf/mstr.pdf" target="_blank" style="font-size:11px;color:var(--muted);text-decoration:none;">pdf ↗</a>
</div>
<div class="ticker-row">
<span class="ticker-symbol">NOVO.B / NVO</span>
<span class="ticker-rating-buy">buy</span>
<span class="ticker-muted">Novo Nordisk · pipeline undervalued</span>
<a href="/static/pdf/novo.pdf" target="_blank" style="font-size:11px;color:var(--muted);text-decoration:none;">pdf ↗</a>
</div>
<div style="margin-top:1.5rem;">
<div class="section-head">
<h2>// add new analysis</h2>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px;">
<div>
<label class="field-label">ticker</label>
<input class="field-input" type="text" name="new_ticker" placeholder="AAPL" />
</div>
<div>
<label class="field-label">rating</label>
<select class="field-select" name="new_rating">
<option value="buy">buy</option>
<option value="sell">sell</option>
<option value="hold">hold</option>
</select>
</div>
</div>
<div style="margin-bottom:10px;">
<label class="field-label">summary</label>
<input class="field-input" type="text" name="new_summary" placeholder="one-line thesis" />
</div>
<div style="margin-bottom:10px;">
<label class="field-label">pdf path</label>
<input class="field-input" type="text" name="new_pdf" placeholder="/static/pdf/aapl.pdf" style="width:300px;" />
</div>
<button class="btn-send"
hx-post="/admin/api/research/add"
hx-include="[name='new_ticker'],[name='new_rating'],[name='new_summary'],[name='new_pdf']"
hx-target="#research-result"
hx-swap="innerHTML">
publish →
</button>
<div id="research-result" style="margin-top:8px;font-size:11px;"></div>
</div>
</div>
</main>
</div>
<script>
function previewEmail() {
const subject = document.getElementById('nl-subject').value;
const body = document.getElementById('nl-body').value;
const w = window.open('', '_blank');
w.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
body{font-family:'IBM Plex Mono',monospace;max-width:600px;margin:40px auto;padding:0 20px;background:#fafaf8;color:#1a1a18;font-size:13px;line-height:1.7;}
h1{font-size:18px;font-weight:500;margin-bottom:4px;}
.sub{font-size:11px;color:#888780;margin-bottom:2rem;}
pre{white-space:pre-wrap;font-family:inherit;}
.footer{margin-top:3rem;padding-top:1rem;border-top:0.5px solid #d3d1c7;font-size:10px;color:#888780;}
</style></head><body>
<h1>${subject || '(no subject)'}</h1>
<div class="sub">samantha42.xyz · equity research</div>
<pre>${body || '(no body)'}</pre>
<div class="footer">samantha42.xyz · me@samantha42.xyz · unsubscribe</div>
</body></html>`);
}
document.querySelectorAll('.sidebar-links a').forEach(a => {
a.addEventListener('click', function() {
document.querySelectorAll('.sidebar-links a').forEach(x => x.classList.remove('active'));
this.classList.add('active');
});
});
</script>
</body>
</html>