basic panel with env based password
This commit is contained in:
@@ -1,12 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
@@ -37,7 +41,7 @@ func saveConfig(c *Config) error {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
err := godotenv.Load()
|
err := godotenv.Load(".env")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error loading .env file")
|
log.Fatal("Error loading .env file")
|
||||||
}
|
}
|
||||||
@@ -58,6 +62,10 @@ func main() {
|
|||||||
http.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/icon.png") })
|
http.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/icon.png") })
|
||||||
http.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/login.html") })
|
http.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/login.html") })
|
||||||
|
|
||||||
|
http.HandleFunc("GET /admin", requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, "./static/admin.html")
|
||||||
|
}))
|
||||||
|
|
||||||
http.HandleFunc("POST /newsletter/subscribe", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("POST /newsletter/subscribe", func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -101,6 +109,40 @@ func main() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.ParseForm()
|
||||||
|
password := r.Form.Get("password")
|
||||||
|
auth := r.Form.Get("totp")
|
||||||
|
|
||||||
|
fmt.Println(os.Getenv("password"), os.Getenv("auth"))
|
||||||
|
if password == os.Getenv("password") && auth == os.Getenv("auth") {
|
||||||
|
fmt.Println("logged in")
|
||||||
|
token := generateSession()
|
||||||
|
|
||||||
|
sessionMu.Lock()
|
||||||
|
sessionStore[token] = time.Now().Add(12 * time.Hour)
|
||||||
|
sessionMu.Unlock()
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Expires: time.Now().Add(12 * time.Hour),
|
||||||
|
})
|
||||||
|
|
||||||
|
// htmx reads this header and does the redirect client-side
|
||||||
|
w.Header().Set("HX-Redirect", "/admin")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
} else {
|
||||||
|
// failure
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, `<div class="error-banner">// incorrect password or 2FA code</div>`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
//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)
|
||||||
@@ -111,3 +153,36 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sessionStore = map[string]time.Time{}
|
||||||
|
var sessionMu sync.RWMutex
|
||||||
|
|
||||||
|
func generateSession() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session")
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMu.RLock()
|
||||||
|
expiry, ok := sessionStore[cookie.Value]
|
||||||
|
sessionMu.RUnlock()
|
||||||
|
|
||||||
|
if !ok || time.Now().After(expiry) {
|
||||||
|
sessionMu.Lock()
|
||||||
|
delete(sessionStore, cookie.Value)
|
||||||
|
sessionMu.Unlock()
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
<!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>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
+14
-2
@@ -18,8 +18,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a class="nav-logo" href="#">samantha_vero_friis</a>
|
<a class="nav-logo" href="#">samantha_vero_friis</a>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a onclick="openOverlay()" >login</a>
|
<a >login</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -436,11 +435,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer-copy">© 2026 — all rights reserved</div>
|
<div class="footer-copy">© 2026 — all rights reserved</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
<div id="login-overlay" style="min-width: 50%; position: fixed; inset: 0; z-index: 100; display: none; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); backdrop-filter: blur(12px);">
|
||||||
|
<div hx-get="login" hx-trigger="load" hx-swap="outerHTML">
|
||||||
|
<span class="spinner">Loading login page...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// just some fun.
|
// just some fun.
|
||||||
console.log("%c👀 hi there!", "font-size:20px")
|
console.log("%c👀 hi there!", "font-size:20px")
|
||||||
|
|
||||||
|
function closelogin() {
|
||||||
|
document.getElementById('login-overlay').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('.nav-links a').addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('login-overlay').style.display = 'flex';
|
||||||
|
});
|
||||||
|
|
||||||
function makeday() {
|
function makeday() {
|
||||||
themeRoot.classList.remove('night');
|
themeRoot.classList.remove('night');
|
||||||
|
|||||||
+14
-16
@@ -1,44 +1,45 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet" />
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.login-wrap { min-height: 520px; display: flex; align-items: center; justify-content: center; padding: 2rem 0; }
|
.login-wrap { min-width: 50%; min-height: 520px; display: flex; align-items: center; justify-content: center; padding: 2rem 0; }
|
||||||
.login-card { background: var(--bg); border: 0.5px solid var(--border); border-radius: 8px; width: 100%; max-width: 400px; overflow: hidden; }
|
.login-card { background: white; border: 0.5px solid var(--color-border-tertiary); border-radius: 8px; width: 100%; max-width: 400px; overflow: hidden; }
|
||||||
.card-header { background: #0C447C; padding: 2rem 2rem 1.5rem; }
|
.card-header { background: #0C447C; padding: 2rem 2rem 1.5rem; }
|
||||||
.brand { font-family: 'DM Serif Display', serif; font-size: 22px; color: #E6F1FB; letter-spacing: -0.5px; margin: 0 0 4px; }
|
.brand { font-family: 'DM Serif Display', serif; font-size: 22px; color: #E6F1FB; letter-spacing: -0.5px; margin: 0 0 4px; }
|
||||||
.brand span { font-style: italic; color: #85B7EB; }
|
.brand span { font-style: italic; color: #85B7EB; }
|
||||||
.brand-sub { font-family: 'DM Mono', monospace; font-size: 11px; color: #378ADD; letter-spacing: 2px; text-transform: uppercase; margin: 0; }
|
.brand-sub { font-family: 'DM Mono', monospace; font-size: 11px; color: #378ADD; letter-spacing: 2px; text-transform: uppercase; margin: 0; }
|
||||||
.card-body { padding: 1.75rem 2rem; }
|
.card-body { padding: 1.75rem 2rem; }
|
||||||
.field-group { margin-bottom: 1rem; }
|
.field-group { margin-bottom: 1rem; }
|
||||||
.field-label { font-family: 'DM Mono', monospace; font-size: 11px; color: var(--muted); letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 6px; }
|
.field-label { font-family: 'DM Mono', monospace; font-size: 11px; color: var(--color-text-secondary); letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 6px; }
|
||||||
.field-input { width: 100%; box-sizing: border-box; font-family: 'DM Mono', monospace; font-size: 14px; padding: 10px 12px; border-radius: 6px; border: 0.5px solid var(--border); background: var(--bg-2); color: var(--text); transition: border-color 0.2s, box-shadow 0.2s; }
|
.field-input { width: 100%; box-sizing: border-box; font-family: 'DM Mono', monospace; font-size: 14px; padding: 10px 12px; border-radius: 6px; border: 0.5px solid var(--color-border-tertiary); background: var(--color-background-secondary); color: var(--color-text-primary); transition: border-color 0.2s, box-shadow 0.2s; }
|
||||||
.field-input:focus { outline: none; border-color: #378ADD; box-shadow: 0 0 0 3px rgba(55,138,221,0.15); }
|
.field-input:focus { outline: none; border-color: #378ADD; box-shadow: 0 0 0 3px rgba(55,138,221,0.15); }
|
||||||
.submit-btn { width: 100%; padding: 11px; background: #185FA5; color: #E6F1FB; border: none; border-radius: 6px; font-family: 'DM Mono', monospace; font-size: 13px; letter-spacing: 1px; cursor: pointer; transition: background 0.2s, transform 0.1s; margin-top: 0.5rem; }
|
.submit-btn { width: 100%; padding: 11px; background: #185FA5; color: #E6F1FB; border: none; border-radius: 6px; font-family: 'DM Mono', monospace; font-size: 13px; letter-spacing: 1px; cursor: pointer; transition: background 0.2s, transform 0.1s; margin-top: 0.5rem; }
|
||||||
.submit-btn:hover { background: #0C447C; }
|
.submit-btn:hover { background: #0C447C; }
|
||||||
.submit-btn:active { transform: scale(0.98); }
|
.submit-btn:active { transform: scale(0.98); }
|
||||||
.field-input.htmx-error { border-color: #E24B4A; box-shadow: 0 0 0 3px rgba(226,75,74,0.1); }
|
.htmx-indicator { opacity: 0; transition: opacity 0.2s; font-family: 'DM Mono', monospace; font-size: 11px; color: var(--color-text-secondary); text-align: center; margin-top: 8px; }
|
||||||
.error-banner { font-family: 'DM Mono', monospace; font-size: 11px; color: #A32D2D; background: #FCEBEB; border: 0.5px solid #F09595; border-radius: 6px; padding: 8px 12px; margin-bottom: 1rem; display: none; }
|
|
||||||
.htmx-indicator { opacity: 0; transition: opacity 0.2s; font-family: 'DM Mono', monospace; font-size: 11px; color: var(--muted); text-align: center; margin-top: 8px; }
|
|
||||||
.htmx-request .htmx-indicator { opacity: 1; }
|
.htmx-request .htmx-indicator { opacity: 1; }
|
||||||
.htmx-request .submit-btn { background: #0C447C; cursor: not-allowed; }
|
.htmx-request .submit-btn { background: #0C447C; cursor: not-allowed; pointer-events: none; }
|
||||||
|
.error-banner { font-family: 'DM Mono', monospace; font-size: 11px; color: #A32D2D; background: #FCEBEB; border: 0.5px solid #F09595; border-radius: 6px; padding: 8px 12px; margin-bottom: 1rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="login-wrap">
|
<div class="login-wrap">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<p class="brand">samantha<span>42</span>.xyz</p>
|
<a style="text-decoration: underline; font-size: 13px; color: #85B7EB; cursor: pointer; font-family: 'DM Mono', monospace;" onclick="closelogin()">← back</a>
|
||||||
|
<p class="brand" style="margin-top: 12px;">samantha<span>42</span>.xyz</p>
|
||||||
<p class="brand-sub">Secure Portal</p>
|
<p class="brand-sub">Secure Portal</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
||||||
<!-- error slot — your server renders this partial on failure -->
|
|
||||||
<div id="login-error"></div>
|
<div id="login-error"></div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
hx-post="/login"
|
hx-post="/login"
|
||||||
hx-target="#login-result"
|
hx-target="#login-error"
|
||||||
hx-swap="outerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-indicator="#login-spinner"
|
hx-indicator="#login-spinner"
|
||||||
|
hx-on::after-request="if(event.detail.successful && event.detail.xhr.status===200 && !event.detail.xhr.getResponseHeader('HX-Redirect')) {}"
|
||||||
>
|
>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label" for="password">Password</label>
|
<label class="field-label" for="password">Password</label>
|
||||||
@@ -69,12 +70,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="submit-btn" type="submit">Continue →</button>
|
<button class="submit-btn" type="submit">Continue →</button>
|
||||||
<p class="htmx-indicator" id="login-spinner">verifying...</p>
|
<p class="htmx-indicator" id="login-spinner">// verifying...</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- server swaps this element on response -->
|
|
||||||
<div id="login-result"></div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
Reference in New Issue
Block a user