516 lines
17 KiB
HTML
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 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> |