Compare commits

..

1 Commits

Author SHA1 Message Date
samantha42
fbc880f40b admin panel, login page with auth session. 2026-04-30 08:02:03 +02:00
13 changed files with 1562 additions and 15 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
data/** data/**
.env

Binary file not shown.

9
go.mod
View File

@@ -2,4 +2,11 @@ module Portifolio
go 1.25.7 go 1.25.7
require github.com/mattn/go-sqlite3 v1.14.37 // indirect require github.com/mattn/go-sqlite3 v1.14.37
require (
github.com/joho/godotenv v1.5.1 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
)
require github.com/FuLygon/go-totp/v2 v2.4.0

6
go.sum
View File

@@ -1,2 +1,8 @@
github.com/FuLygon/go-totp/v2 v2.4.0 h1:NTgab5GCVHHGjIjjOzRKKKxa6PhMI9o/fs2fFrJqqhE=
github.com/FuLygon/go-totp/v2 v2.4.0/go.mod h1:bEwZp8rat339MqPzXZ7FDl5eOJuXpQ35aYbk1qZbWLM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=

View File

@@ -0,0 +1,58 @@
package middleware
import (
"net/http"
"sync"
)
// In-memory session store — swap for Redis/DB in production
var (
sessionStore = make(map[string]bool)
mu sync.RWMutex
)
// RegisterSession adds a token to the store (call this after login)
func RegisterSession(token string) {
mu.Lock()
defer mu.Unlock()
sessionStore[token] = true
}
// RevokeSession removes a token (call this on logout)
func RevokeSession(token string) {
mu.Lock()
defer mu.Unlock()
delete(sessionStore, token)
}
// RequireSession is the middleware — wraps any handler that needs auth
func RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
// No cookie at all
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
mu.RLock()
valid := sessionStore[cookie.Value]
mu.RUnlock()
if !valid {
// Cookie exists but token is unknown/expired
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
MaxAge: -1, // delete it
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -1,13 +1,19 @@
package website package website
import ( import (
"Portifolio/internal/middleware"
"Portifolio/internal/model" "Portifolio/internal/model"
"Portifolio/internal/service" "Portifolio/internal/service"
"crypto/rand"
_ "embed" _ "embed"
"encoding/base64"
"fmt" "fmt"
"net/http" "net/http"
"os"
"strconv" "strconv"
"time"
"github.com/FuLygon/go-totp/v2"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -21,13 +27,136 @@ func Getsite() http.HandlerFunc {
} }
} }
//go:embed static/styles.css //go:embed static/login.html
var styleCSS []byte var loginxHTML []byte
func Getstylesheet() http.HandlerFunc { func GetAdminLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(loginxHTML)
}
}
type contextKey string
const ContextKeyRequestTime contextKey = "requestTime"
func LoginHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// checked once when the handler is registered
envUsername := os.Getenv("username")
envPassword := os.Getenv("password")
envTOTPSecret := os.Getenv("AUTH_TOTP_SECRET")
if envUsername == "" || envPassword == "" || envTOTPSecret == "" {
http.Error(w, "env var missing", http.StatusInternalServerError)
}
ts, ok := r.Context().Value(ContextKeyRequestTime).(time.Time)
if !ok {
ts = time.Now()
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form data", http.StatusBadRequest)
return
}
password := r.FormValue("password")
username := r.FormValue("username")
code := r.FormValue("auth_code")
fmt.Println(username, password, code)
if password == "" || username == "" || code == "" {
http.Error(w, "form value is empty", http.StatusBadRequest)
return
}
if password != envPassword || username != envUsername {
http.Error(w, "username or password is not valid", http.StatusUnauthorized)
return
}
v := totp.Validator{
Algorithm: totp.AlgorithmSHA1,
Digits: 6,
Period: 30,
Secret: envTOTPSecret,
}
valid, err := v.ValidateWithTimestamp(code, ts.Unix())
if err != nil {
http.Error(w, fmt.Sprintf("error validating TOTP code: %s", err), http.StatusUnauthorized)
return
}
if !valid {
http.Error(w, "invalid auth code", http.StatusUnauthorized)
return
}
// In your login POST handler, after setting the cookie:
token := generateSessionToken()
middleware.RegisterSession(token) // <-- register before writing cookie
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
w.Header().Set("HX-Redirect", "/admin")
w.WriteHeader(http.StatusOK)
}
}
// Simple session token generator
func generateSessionToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.URLEncoding.EncodeToString(b)
}
//go:embed static/admin.html
var adminHTML []byte
func GetAdmin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(adminHTML)
}
}
//go:embed static/styles.css
var styleCSSmain []byte
func GetstylesheetMain() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(styleCSS) w.Write(styleCSSmain)
}
}
//go:embed static/login.css
var styleCSSlogin []byte
func GetstylesheetLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(styleCSSlogin)
}
}
//go:embed static/admin.css
var styleCSSadmin []byte
func GetstylesheetAdmin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(styleCSSadmin)
} }
} }

View File

@@ -0,0 +1,444 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Fraunces:ital,wght@0,300;0,400;0,600;1,300;1,400&display=swap');
:root {
--bg: #f5efe7;;
--surface: #efefef;
--surface2:#e0e0e0;
--border: #8c8c8c;
--border2: #acacac;
--accent: #c8a96e;
--accent2: #f0d060;
--muted: #5a5a68;
--muted2: #3d3d4a;
--text: #000000;
--text-dim: #9e9a92;
--red: #f06060;
--blue: #60c0f0;
--radius: 2px;
--mono: 'Space Mono', monospace;
--serif: 'Fraunces', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--mono);
min-height: 100vh;
}
/* GRID LAYOUT */
.app {
display: grid;
grid-template-columns: 220px 1fr;
grid-template-rows: 56px 1fr;
min-height: 100vh;
}
/* TOP NAV */
.topbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 24px;
padding: 0 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.topbar-logo {
font-family: var(--serif);
font-size: 1rem;
font-style: italic;
font-weight: 300;
color: var(--text);
text-decoration: none;
}
.topbar-logo span { color: var(--accent); }
.topbar-tag {
font-size: 0.71rem;
color: var(--muted2);
letter-spacing: 0.04em;
}
.topbar-spacer { flex: 1; }
.status-pill {
display: flex;
align-items: center;
gap: 7px;
font-size: 0.71rem;
color: var(--muted2);
}
.dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--accent);
animation: pulse 2.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.dot.err { background: var(--red); animation: none; }
.dot.loading { background: var(--muted); animation: pulse 1s linear infinite; }
/* SIDEBAR */
.sidebar {
border-right: 1px solid var(--border);
background: var(--surface);
padding: 20px 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-section-label {
font-size: 0.62rem;
color: var(--muted);
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 10px 18px 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 18px;
font-size: 0.75rem;
color: var(--muted2);
text-decoration: none;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
transition: color 0.15s, background 0.15s;
border-left: 2px solid transparent;
}
.nav-item:hover { color: var(--text); background: var(--surface2); }
.nav-item.active { color: var(--accent); border-left-color: var(--accent); background: rgba(200,240,96,0.05); }
.nav-icon { width: 14px; opacity: 0.7; font-style: normal; }
/* MAIN */
.main {
padding: 28px 32px;
overflow-y: auto;
max-height: calc(100vh - 56px);
}
/* PAGE HEADER */
.page-header {
margin-bottom: 28px;
display: flex;
align-items: flex-end;
gap: 16px;
}
.page-title {
font-family: var(--serif);
font-size: 2rem;
font-weight: 300;
line-height: 1;
}
.page-title span { font-style: italic; color: var(--accent); }
.page-subtitle {
font-size: 0.68rem;
color: var(--muted2);
padding-bottom: 4px;
letter-spacing: 0.04em;
}
/* SECTION */
.section { margin-bottom: 32px; }
.section-label {
font-size: 0.68rem;
color: var(--muted2);
letter-spacing: 0.08em;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.section-label::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.section-label span { color: var(--accent); }
/* SUMMARY CARDS */
.cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 32px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
padding: 16px 18px;
}
.card-label {
font-size: 0.65rem;
color: var(--muted2);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 8px;
}
.card-value {
font-family: var(--serif);
font-size: 1.6rem;
font-weight: 300;
color: var(--text);
}
.card-value.accent { color: var(--accent); }
.card-value.dim { color: var(--muted); }
/* PANEL / FORM */
.panel {
background: var(--surface);
border: 1px solid var(--border);
}
.panel-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
}
.panel-title {
font-size: 0.75rem;
letter-spacing: 0.04em;
}
.panel-title span { color: var(--accent); }
.panel-body { padding: 20px; }
/* FORM GRID */
.form-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.form-grid.two { grid-template-columns: repeat(2, 1fr); }
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group.full { grid-column: 1 / -1; }
.form-label {
font-size: 0.65rem;
color: var(--muted2);
letter-spacing: 0.07em;
text-transform: uppercase;
}
.form-label .required { color: var(--red); margin-left: 3px; }
input, select, textarea {
background: var(--bg);
border: 1px solid var(--border2);
color: var(--text);
font-family: var(--mono);
font-size: 0.78rem;
padding: 9px 12px;
border-radius: var(--radius);
outline: none;
width: 100%;
transition: border-color 0.15s, box-shadow 0.15s;
-webkit-appearance: none;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(200,240,96,0.1);
}
input::placeholder { color: var(--muted); }
select option { background: var(--surface2); }
.type-toggle {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
border: 1px solid var(--border2);
border-radius: var(--radius);
overflow: hidden;
}
.type-btn {
padding: 9px 8px;
font-family: var(--mono);
font-size: 0.72rem;
border: none;
cursor: pointer;
background: var(--bg);
color: var(--muted2);
transition: background 0.15s, color 0.15s;
letter-spacing: 0.03em;
border-right: 1px solid var(--border2);
}
.type-btn:last-child { border-right: none; }
.type-btn.active-buy { background: rgba(96,192,240,0.12); color: var(--blue); }
.type-btn.active-sell { background: rgba(240,96,96,0.12); color: var(--red); }
.type-btn.active-div { background: rgba(200,240,96,0.12); color: var(--accent); }
/* DIVIDEND FIELDS */
.dividend-fields {
grid-column: 1 / -1;
display: none;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
margin-top: 4px;
}
.dividend-fields.visible { display: grid; }
.div-label {
grid-column: 1 / -1;
font-size: 0.63rem;
color: var(--accent);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: -8px;
}
/* FORM ACTIONS */
.form-actions {
display: flex;
align-items: center;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--border);
margin-top: 4px;
grid-column: 1 / -1;
}
.btn {
font-family: var(--mono);
font-size: 0.72rem;
padding: 9px 20px;
border: 1px solid transparent;
cursor: pointer;
letter-spacing: 0.04em;
transition: background 0.15s, color 0.15s, transform 0.1s;
border-radius: var(--radius);
}
.btn:active { transform: scale(0.98); }
.btn-primary {
background: var(--accent);
color: #0d0d0d;
font-weight: 700;
}
.btn-primary:hover { background: #d8ff70; }
.btn-ghost {
background: transparent;
color: var(--muted2);
border-color: var(--border2);
}
.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
/* FEEDBACK */
.feedback {
font-size: 0.72rem;
padding: 4px 12px;
border-radius: var(--radius);
display: none;
}
.feedback.success { display: inline; color: var(--accent); }
.feedback.error { display: inline; color: var(--red); }
/* TABLE */
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}
thead {
background: var(--surface2);
}
th {
padding: 10px 14px;
text-align: left;
font-size: 0.62rem;
font-weight: 400;
color: var(--muted2);
letter-spacing: 0.07em;
text-transform: uppercase;
white-space: nowrap;
border-bottom: 1px solid var(--border);
}
td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
color: var(--text);
white-space: nowrap;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.025); }
.td-sym {
font-weight: 700;
color: var(--accent);
font-size: 0.78rem;
}
.td-muted { color: var(--muted2); }
.td-buy { color: var(--blue); }
.td-sell { color: var(--red); }
.td-div { color: var(--accent); }
.td-right { text-align: right; }
.badge {
font-size: 0.62rem;
padding: 2px 8px;
border-radius: 20px;
letter-spacing: 0.04em;
}
.badge-buy { background: rgba(96,192,240,0.12); color: var(--blue); }
.badge-sell { background: rgba(240,96,96,0.12); color: var(--red); }
.badge-div { background: rgba(200,240,96,0.12); color: var(--accent); }
/* TABS */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: -1px;
}
.tab {
padding: 10px 20px;
font-size: 0.72rem;
cursor: pointer;
color: var(--muted2);
border: none;
background: none;
font-family: var(--mono);
border-bottom: 2px solid transparent;
transition: color 0.15s;
letter-spacing: 0.03em;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
/* PILL TAG */
.tag-pill {
font-size: 0.60rem;
padding: 2px 7px;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--muted2);
border-radius: 20px;
letter-spacing: 0.04em;
}
/* PAGES */
.page { display: none; }
.page.active { display: block; }
/* RESPONSIVE NOTE */
@media(max-width:900px) {
.app { grid-template-columns: 1fr; }
.sidebar { display: none; }
.cards { grid-template-columns: repeat(2, 1fr); }
.form-grid { grid-template-columns: 1fr; }
.dividend-fields { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,582 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Portfolio Admin — Samantha Friis</title>
<link rel="stylesheet" href="/styles/admin">
</head>
<body>
<div class="app">
<!-- TOP BAR -->
<header class="topbar">
<a href="/" class="topbar-logo">Samantha <span>Friis</span></a>
<span class="topbar-tag">// portfolio admin</span>
<div class="topbar-spacer"></div>
<div class="status-pill">
<span class="dot" id="status-dot"></span>
<span id="status-text">live</span>
</div>
</header>
<!-- SIDEBAR -->
<nav class="sidebar">
<span class="nav-section-label">overview</span>
<button class="nav-item active" onclick="navigate('dashboard', this)">
<i class="nav-icon"></i> Dashboard
</button>
<button class="nav-item" onclick="navigate('positions', this)">
<i class="nav-icon"></i> Positions
</button>
<button class="nav-item" onclick="navigate('history', this)">
<i class="nav-icon"></i> Trade History
</button>
<span class="nav-section-label" style="margin-top:12px">add data</span>
<button class="nav-item" onclick="navigate('add-trade', this)">
<i class="nav-icon"></i> Add Trade
</button>
<button class="nav-item" onclick="navigate('add-company', this)">
<i class="nav-icon"></i> Add Company
</button>
</nav>
<!-- MAIN CONTENT -->
<main class="main">
<!-- ═══ DASHBOARD ═══ -->
<div class="page active" id="page-dashboard">
<div class="page-header">
<h1 class="page-title">Investment <span>Portfolio</span></h1>
<span class="page-subtitle">Equity positions &nbsp;·&nbsp; Personal research</span>
</div>
<div class="cards">
<div class="card">
<p class="card-label">Positions</p>
<p class="card-value accent" id="stat-positions"></p>
</div>
<div class="card">
<p class="card-label">Total Shares</p>
<p class="card-value" id="stat-shares"></p>
</div>
<div class="card">
<p class="card-label">Total Trades</p>
<p class="card-value" id="stat-trades"></p>
</div>
<div class="card">
<p class="card-label">Currencies</p>
<p class="card-value" id="stat-currencies"></p>
</div>
</div>
<p class="section-label">// <span>positions</span></p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Symbol</th><th>Currency</th><th>Shares</th>
<th>Cost Basis</th><th>Weight</th>
</tr>
</thead>
<tbody hx-get="/positions/fragment"
hx-trigger="load"
hx-swap="innerHTML">
<tr class="state-row"><td colspan="5">loading…</td></tr>
</tbody>
</table>
</div>
<p class="section-label" style="margin-top:28px">// <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="companies-body">
<tr><td colspan="5" class="td-muted">loading…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ═══ POSITIONS ═══ -->
<div class="page" id="page-positions">
<div class="page-header">
<h1 class="page-title">Open <span>Positions</span></h1>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Symbol</th><th>Currency</th><th>Shares</th>
<th>Cost Basis</th><th>Weight</th>
</tr>
</thead>
<tbody hx-get="/positions/fragment"
hx-trigger="load"
hx-swap="innerHTML">
<tr class="state-row"><td colspan="5">loading…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ═══ HISTORY ═══ -->
<div class="page" id="page-history">
<div class="page-header">
<h1 class="page-title">Trade <span>History</span></h1>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th><th>Symbol</th><th>Type</th>
<th>Shares</th><th>Price</th><th>Currency</th><th>Total</th>
</tr>
</thead>
<tbody id="history-body">
<tr><td colspan="7" class="td-muted">loading…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ═══ ADD TRADE ═══ -->
<div class="page" id="page-add-trade">
<div class="page-header">
<h1 class="page-title">Add <span>Trade</span></h1>
<span class="page-subtitle">Record a buy, sell, or dividend</span>
</div>
<div class="panel">
<div class="panel-header">
<span class="panel-title">// <span>new trade</span></span>
<span class="tag-pill">POST /trades</span>
</div>
<div class="panel-body">
<div class="form-grid" id="trade-form">
<!-- Type toggle -->
<div class="form-group">
<label class="form-label">Trade type <span class="required">*</span></label>
<div class="type-toggle">
<button class="type-btn active-buy" data-val="0" onclick="setTradeType(0, this)">BUY</button>
<button class="type-btn" data-val="1" onclick="setTradeType(1, this)">SELL</button>
<button class="type-btn" data-val="2" onclick="setTradeType(2, this)">DIVIDEND</button>
</div>
<input type="hidden" id="trade-type" value="0"/>
</div>
<!-- Symbol -->
<div class="form-group">
<label class="form-label" for="trade-symbol">Symbol <span class="required">*</span></label>
<input type="text" id="trade-symbol" placeholder="AAPL" style="text-transform:uppercase"/>
</div>
<!-- Date -->
<div class="form-group">
<label class="form-label" for="trade-date">Date <span class="required">*</span></label>
<input type="date" id="trade-date"/>
</div>
<!-- Shares -->
<div class="form-group">
<label class="form-label" for="trade-shares">Shares <span class="required">*</span></label>
<input type="number" id="trade-shares" placeholder="100" min="0" step="1"/>
</div>
<!-- Price -->
<div class="form-group">
<label class="form-label" for="trade-price">Price per share <span class="required">*</span></label>
<input type="number" id="trade-price" placeholder="0.00" min="0" step="0.01"/>
</div>
<!-- Currency -->
<div class="form-group">
<label class="form-label" for="trade-currency">Currency <span class="required">*</span></label>
<select id="trade-currency">
<option value="USD">USD — US Dollar</option>
<option value="EUR">EUR — Euro</option>
<option value="GBP">GBP — British Pound</option>
<option value="DKK">DKK — Danish Krone</option>
<option value="SEK">SEK — Swedish Krona</option>
<option value="NOK">NOK — Norwegian Krone</option>
<option value="JPY">JPY — Japanese Yen</option>
<option value="CHF">CHF — Swiss Franc</option>
<option value="CAD">CAD — Canadian Dollar</option>
</select>
</div>
<!-- Product ID -->
<div class="form-group">
<label class="form-label" for="trade-product">Product ID</label>
<input type="number" id="trade-product" placeholder="0" min="0" step="1"/>
</div>
<!-- DIVIDEND-ONLY FIELDS -->
<div class="dividend-fields" id="dividend-fields">
<span class="div-label">dividend fields</span>
<div class="form-group">
<label class="form-label" for="div-net">Net value</label>
<input type="number" id="div-net" placeholder="0.00" step="0.01"/>
</div>
<div class="form-group">
<label class="form-label" for="div-tax-amount">Tax amount</label>
<input type="number" id="div-tax-amount" placeholder="0.00" step="0.01"/>
</div>
<div class="form-group">
<label class="form-label" for="div-tax-rate">Tax rate</label>
<input type="number" id="div-tax-rate" placeholder="0.00" step="0.0001" min="0" max="1"/>
</div>
<div class="form-group">
<label class="form-label" for="div-payment-date">Payment date</label>
<input type="date" id="div-payment-date"/>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<button class="btn btn-primary" onclick="submitTrade()">Submit trade →</button>
<button class="btn btn-ghost" onclick="resetTradeForm()">Clear</button>
<span class="feedback" id="trade-feedback"></span>
</div>
</div>
</div>
</div>
<!-- Live preview -->
<div class="panel" style="margin-top:16px">
<div class="panel-header">
<span class="panel-title">// <span>payload preview</span></span>
</div>
<div class="panel-body">
<pre id="trade-preview" style="font-size:0.72rem;color:var(--muted2);line-height:1.7;white-space:pre-wrap">{}</pre>
</div>
</div>
</div>
<!-- ═══ ADD COMPANY ═══ -->
<div class="page" id="page-add-company">
<div class="page-header">
<h1 class="page-title">Add <span>Company</span></h1>
<span class="page-subtitle">Track a new company in the universe</span>
</div>
<div class="panel">
<div class="panel-header">
<span class="panel-title">// <span>new company</span></span>
<span class="tag-pill">POST /company</span>
</div>
<div class="panel-body">
<div class="form-grid two">
<div class="form-group">
<label class="form-label" for="co-symbol">Symbol <span class="required">*</span></label>
<input type="text" id="co-symbol" placeholder="AAPL" style="text-transform:uppercase"/>
</div>
<div class="form-group">
<label class="form-label" for="co-currency-code">Currency code <span class="required">*</span></label>
<select id="co-currency-code">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
<option value="DKK">DKK</option>
<option value="SEK">SEK</option>
<option value="NOK">NOK</option>
<option value="JPY">JPY</option>
<option value="CHF">CHF</option>
<option value="CAD">CAD</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="co-currency-id">Currency ID <span class="required">*</span></label>
<input type="number" id="co-currency-id" placeholder="1" min="1" step="1"/>
</div>
<div class="form-group">
<label class="form-label" for="co-price">Current price <span class="required">*</span></label>
<input type="number" id="co-price" placeholder="0.00" step="0.01"/>
</div>
<div class="form-group full">
<label class="form-label" for="co-shares-out">Shares outstanding <span class="required">*</span></label>
<input type="number" id="co-shares-out" placeholder="1000000000" min="0" step="1"/>
</div>
<div class="form-actions">
<button class="btn btn-primary" onclick="submitCompany()">Add company →</button>
<button class="btn btn-ghost" onclick="resetCompanyForm()">Clear</button>
<span class="feedback" id="company-feedback"></span>
</div>
</div>
</div>
</div>
<div class="panel" style="margin-top:16px">
<div class="panel-header">
<span class="panel-title">// <span>payload preview</span></span>
</div>
<div class="panel-body">
<pre id="company-preview" style="font-size:0.72rem;color:var(--muted2);line-height:1.7;white-space:pre-wrap">{}</pre>
</div>
</div>
</div>
</main>
</div>
<script>
// ── MOCK DATA ────────────────────────────────────────────
const MOCK_POSITIONS = [
{ symbol:'AAPL', currency:'USD', shares:150, costBasis:22050, weight:'34.2%' },
{ symbol:'MSFT', currency:'USD', shares:80, costBasis:28800, weight:'27.1%' },
{ symbol:'NOVO B', currency:'DKK', shares:200, costBasis:18000, weight:'25.5%' },
{ symbol:'ASML', currency:'EUR', shares:15, costBasis:11250, weight:'13.2%' },
];
const MOCK_COMPANIES = [
{ symbol:'AAPL', currency:'USD', price:182.50, sharesOut:'15.4B', mktCap:'2.81T' },
{ symbol:'MSFT', currency:'USD', price:360.00, sharesOut:'7.4B', mktCap:'2.66T' },
{ symbol:'NOVO B', currency:'DKK', price:635.00, sharesOut:'2.2B', mktCap:'1.40T' },
{ symbol:'ASML', currency:'EUR', price:750.00, sharesOut:'406M', mktCap:'304B' },
];
const MOCK_TRADES = [
{ date:'2026-04-15', symbol:'AAPL', type:0, shares:50, price:175.20, currency:'USD', total:8760.00 },
{ date:'2026-03-22', symbol:'MSFT', type:0, shares:20, price:355.00, currency:'USD', total:7100.00 },
{ date:'2026-03-01', symbol:'NOVO B', type:2, shares:200, price:18.50, currency:'DKK', total:3700.00 },
{ date:'2026-02-10', symbol:'AAPL', type:1, shares:10, price:180.00, currency:'USD', total:1800.00 },
];
// ── NAV ──────────────────────────────────────────────────
function navigate(page, el) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
if (el) el.classList.add('active');
if (page === 'dashboard' || page === 'positions') loadPositions();
if (page === 'history') loadHistory();
}
// ── RENDER TABLES ────────────────────────────────────────
function loadPositions() {
const total = MOCK_POSITIONS.reduce((s,p)=>s+p.costBasis,0);
const tbody1 = document.getElementById('positions-body');
const tbody2 = document.getElementById('positions-body-2');
const rows = MOCK_POSITIONS.map(p => `
<tr>
<td class="td-sym">${p.symbol}</td>
<td class="td-muted">${p.currency}</td>
<td>${p.shares.toLocaleString()}</td>
<td>${p.costBasis.toLocaleString(undefined,{minimumFractionDigits:2})}</td>
<td class="td-muted">${p.weight}</td>
</tr>`).join('');
if (tbody1) tbody1.innerHTML = rows;
if (tbody2) tbody2.innerHTML = rows;
// summary stats
document.getElementById('stat-positions').textContent = MOCK_POSITIONS.length;
document.getElementById('stat-shares').textContent = MOCK_POSITIONS.reduce((s,p)=>s+p.shares,0).toLocaleString();
document.getElementById('stat-trades').textContent = MOCK_TRADES.length;
document.getElementById('stat-currencies').textContent = [...new Set(MOCK_POSITIONS.map(p=>p.currency))].length;
const tbody3 = document.getElementById('companies-body');
if (tbody3) tbody3.innerHTML = MOCK_COMPANIES.map(c=>`
<tr>
<td class="td-sym">${c.symbol}</td>
<td class="td-muted">${c.currency}</td>
<td>${c.price.toLocaleString(undefined,{minimumFractionDigits:2})}</td>
<td class="td-muted">${c.sharesOut}</td>
<td>${c.mktCap}</td>
</tr>`).join('');
}
function loadHistory() {
const typeMap = ['BUY','SELL','DIVIDEND'];
const classMap = ['td-buy','td-sell','td-div'];
const badgeMap = ['badge-buy','badge-sell','badge-div'];
document.getElementById('history-body').innerHTML = MOCK_TRADES.map(t=>`
<tr>
<td class="td-muted">${t.date}</td>
<td class="td-sym">${t.symbol}</td>
<td><span class="badge ${badgeMap[t.type]}">${typeMap[t.type]}</span></td>
<td>${t.shares}</td>
<td>${t.price.toFixed(2)}</td>
<td class="td-muted">${t.currency}</td>
<td>${t.total.toLocaleString(undefined,{minimumFractionDigits:2})}</td>
</tr>`).join('');
}
// ── TRADE TYPE TOGGLE ─────────────────────────────────────
function setTradeType(val, btn) {
document.querySelectorAll('.type-btn').forEach(b => {
b.classList.remove('active-buy','active-sell','active-div');
});
const cls = ['active-buy','active-sell','active-div'][val];
btn.classList.add(cls);
document.getElementById('trade-type').value = val;
const df = document.getElementById('dividend-fields');
df.classList.toggle('visible', val === 2);
updateTradePreview();
}
// ── PAYLOAD PREVIEW ──────────────────────────────────────
function tradePayload() {
const type = parseInt(document.getElementById('trade-type').value);
const p = {
symbol: document.getElementById('trade-symbol').value.toUpperCase() || '',
shares: parseInt(document.getElementById('trade-shares').value) || 0,
product: parseInt(document.getElementById('trade-product').value) || 0,
type,
price: parseFloat(document.getElementById('trade-price').value) || 0,
currency_code: document.getElementById('trade-currency').value,
date: document.getElementById('trade-date').value || new Date().toISOString().split('T')[0],
};
if (type === 2) {
p.net_value = parseFloat(document.getElementById('div-net').value) || 0;
p.tax_amount = parseFloat(document.getElementById('div-tax-amount').value) || 0;
p.tax_rate = parseFloat(document.getElementById('div-tax-rate').value) || 0;
p.payment_date = document.getElementById('div-payment-date').value || '';
}
return p;
}
function updateTradePreview() {
document.getElementById('trade-preview').textContent =
JSON.stringify(tradePayload(), null, 2);
}
function updateCompanyPreview() {
const p = {
symbol: document.getElementById('co-symbol').value.toUpperCase() || '',
shares_outstanding: parseInt(document.getElementById('co-shares-out').value) || 0,
price: parseFloat(document.getElementById('co-price').value) || 0,
currency_id: parseInt(document.getElementById('co-currency-id').value) || 0,
currency_code: document.getElementById('co-currency-code').value,
};
document.getElementById('company-preview').textContent =
JSON.stringify(p, null, 2);
}
// Hook preview update to all trade inputs
document.querySelectorAll('#trade-form input, #trade-form select').forEach(el => {
el.addEventListener('input', updateTradePreview);
});
// ── SUBMIT TRADE ─────────────────────────────────────────
async function submitTrade() {
const payload = tradePayload();
const fb = document.getElementById('trade-feedback');
fb.className = 'feedback';
if (!payload.symbol || !payload.price || !payload.shares) {
fb.textContent = '✗ fill in required fields';
fb.className = 'feedback error';
return;
}
try {
const res = await fetch('/trades', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
fb.textContent = '✓ trade recorded';
fb.className = 'feedback success';
// Add to mock history for demo
MOCK_TRADES.unshift({
date: payload.date, symbol: payload.symbol, type: payload.type,
shares: payload.shares, price: payload.price,
currency: payload.currency_code,
total: payload.shares * payload.price,
});
setTimeout(() => { fb.className = 'feedback'; }, 3000);
} catch(e) {
// For demo: show success anyway (backend not connected)
fb.textContent = '✓ trade recorded (demo)';
fb.className = 'feedback success';
setTimeout(() => { fb.className = 'feedback'; }, 3000);
}
}
async function submitCompany() {
const fb = document.getElementById('company-feedback');
fb.className = 'feedback';
const payload = {
symbol: document.getElementById('co-symbol').value.toUpperCase(),
shares_outstanding: parseInt(document.getElementById('co-shares-out').value) || 0,
price: parseFloat(document.getElementById('co-price').value) || 0,
currency_id: parseInt(document.getElementById('co-currency-id').value) || 0,
currency_code: document.getElementById('co-currency-code').value,
};
if (!payload.symbol || !payload.price) {
fb.textContent = '✗ fill in required fields';
fb.className = 'feedback error';
return;
}
try {
const res = await fetch('/company', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`${res.status}`);
fb.textContent = '✓ company added';
fb.className = 'feedback success';
} catch {
fb.textContent = '✓ company added (demo)';
fb.className = 'feedback success';
}
setTimeout(() => { fb.className = 'feedback'; }, 3000);
}
function resetTradeForm() {
['trade-symbol','trade-shares','trade-price','trade-product',
'div-net','div-tax-amount','div-tax-rate','div-payment-date'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
document.getElementById('trade-date').value = '';
document.getElementById('trade-type').value = '0';
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active-buy','active-sell','active-div'));
document.querySelector('.type-btn[data-val="0"]').classList.add('active-buy');
document.getElementById('dividend-fields').classList.remove('visible');
document.getElementById('trade-feedback').className = 'feedback';
updateTradePreview();
}
function resetCompanyForm() {
['co-symbol','co-currency-id','co-price','co-shares-out'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('company-feedback').className = 'feedback';
updateCompanyPreview();
}
// ── COMPANY FORM PREVIEW HOOKS ───────────────────────────
['co-symbol','co-currency-id','co-price','co-shares-out','co-currency-code'].forEach(id => {
document.getElementById(id).addEventListener('input', updateCompanyPreview);
});
// ── INIT ─────────────────────────────────────────────────
loadPositions();
const today = new Date().toISOString().split('T')[0];
document.getElementById('trade-date').value = today;
updateTradePreview();
updateCompanyPreview();
</script>
</body>
</html>

View File

@@ -5,14 +5,15 @@
<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=Space+Mono:wght@400;700&family=Fraunces:ital,wght@0,300;0,400;0,600;1,300;1,400&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="/style"> <link rel="stylesheet" href="/styles/main">
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<a href="/" class="back">← back</a> <a href="/" class="back">← back,</a>
<a href="/login" class="back"> login</a>
<p class="tag">// portfolio</p> <p class="tag">// portfolio</p>
<h1>Investment<br/><span>Portfolio</span></h1> <h1>Investment<br/><span>Portfolio</span></h1>
<p class="header-sub">Equity positions &nbsp;·&nbsp; Personal research</p> <p class="header-sub">Equity positions &nbsp;·&nbsp; Personal research</p>

View File

@@ -0,0 +1,224 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #1a1714;
--paper: #f5f0e8;
--accent: #c4622d;
--muted: #8c8070;
--border: #d6cfc3;
--field-bg: #ece7dd;
}
html, body {
height: 100%;
background: var(--paper);
color: var(--ink);
font-family: 'Space Mono', monospace;
font-size: 13px;
}
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
/* Grain overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
opacity: 0.5;
}
.container {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
}
header {
margin-bottom: 40px;
}
.back {
display: inline-block;
font-family: 'Space Mono', monospace;
font-size: 11px;
color: var(--muted);
text-decoration: none;
letter-spacing: 0.04em;
margin-bottom: 28px;
transition: color 0.2s;
}
.back:hover { color: var(--ink); }
.tag {
font-family: 'Space Mono', monospace;
font-size: 11px;
color: var(--accent);
letter-spacing: 0.08em;
margin-bottom: 10px;
}
h1 {
font-family: 'Fraunces', serif;
font-weight: 300;
font-size: clamp(36px, 8vw, 52px);
line-height: 1.05;
letter-spacing: -0.02em;
color: var(--ink);
}
h1 span {
font-style: italic;
color: var(--accent);
}
.header-sub {
margin-top: 10px;
font-size: 11px;
color: var(--muted);
letter-spacing: 0.06em;
}
/* Rule */
.rule {
width: 100%;
height: 1px;
background: var(--border);
margin: 32px 0;
}
/* Form */
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 7px;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 11px 14px;
background: var(--field-bg);
border: 1px solid var(--border);
border-radius: 2px;
font-family: 'Space Mono', monospace;
font-size: 13px;
color: var(--ink);
outline: none;
transition: border-color 0.2s, background 0.2s;
-webkit-appearance: none;
}
input[type="text"]:focus,
input[type="password"]:focus {
border-color: var(--accent);
background: var(--paper);
}
input::placeholder { color: var(--muted); opacity: 0.7; }
.form-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 28px;
gap: 16px;
}
.btn-login {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--ink);
color: var(--paper);
font-family: 'Space Mono', monospace;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
border: none;
padding: 12px 24px;
cursor: pointer;
border-radius: 2px;
transition: background 0.2s, transform 0.15s;
}
.btn-login:hover {
background: var(--accent);
transform: translateY(-1px);
}
.btn-login:active { transform: translateY(0); }
.btn-login svg {
transition: transform 0.2s;
}
.btn-login:hover svg { transform: translateX(3px); }
.forgot {
font-size: 11px;
color: var(--muted);
text-decoration: none;
letter-spacing: 0.03em;
border-bottom: 1px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
.forgot:hover { color: var(--ink); border-color: var(--ink); }
footer {
margin-top: 48px;
display: flex;
justify-content: space-between;
align-items: flex-end;
border-top: 1px solid var(--border);
padding-top: 16px;
animation: rise 0.6s 0.15s cubic-bezier(0.22, 1, 0.36, 1) both;
}
footer a {
font-size: 10px;
color: var(--muted);
text-decoration: none;
letter-spacing: 0.04em;
transition: color 0.2s;
}
footer a:hover { color: var(--accent); }
.footer-copy {
font-size: 10px;
color: var(--border);
letter-spacing: 0.04em;
}
/* Decorative corner mark */
.corner-mark {
position: fixed;
bottom: 24px;
right: 28px;
font-family: 'Fraunces', serif;
font-style: italic;
font-size: 11px;
color: var(--border);
letter-spacing: 0.06em;
z-index: 1;
}

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Portfolio — Samantha Friis</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Fraunces:ital,wght@0,300;0,400;0,600;1,300;1,400&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/styles/login">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<div class="container">
<header>
<a href="/" class="back">← back</a>
<p class="tag">// portfolio</p>
<h1>Investment<br/><span>Portfolio</span></h1>
<p class="header-sub">Equity positions &nbsp;·&nbsp; Personal research</p>
</header>
<div class="rule"></div>
<form
hx-post="/auth/login"
hx-target="#login-response"
hx-swap="innerHTML"
hx-indicator=".btn-login"
>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="your handle" autocomplete="username" required />
</div>
<div class="form-group">
<label for="password">Passphrase</label>
<input type="password" id="password" name="password" placeholder="········" autocomplete="current-password" required />
</div>
<div class="form-group">
<label>Auth Code</label>
<input type="text" id="auth-code" name="auth_code" />
</div>
<div class="form-footer">
<button class="btn-login" type="submit">
<span class="btn-label">
Enter
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 5H13M9 1L13 5L9 9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="btn-spinner"></span>
</button>
</div>
<div id="login-response"></div>
</form>
<footer>
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
<p class="footer-copy">&copy; 2026 — All rights reserved</p>
</footer>
</div>
<p class="corner-mark">SF — 2026</p>
</body>
</html>

View File

@@ -1,5 +1,5 @@
:root { :root {
--bg: #f4f4f4; --bg: #f5efe7;;
--surface: #efefef; --surface: #efefef;
--surface2:#e0e0e0; --surface2:#e0e0e0;
--border: #8c8c8c; --border: #8c8c8c;

40
main.go
View File

@@ -2,6 +2,7 @@ package main
import ( import (
"Portifolio/internal/database" "Portifolio/internal/database"
"Portifolio/internal/middleware"
"Portifolio/internal/service" "Portifolio/internal/service"
"Portifolio/internal/website" "Portifolio/internal/website"
"database/sql" "database/sql"
@@ -9,6 +10,9 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -32,12 +36,32 @@ func corsMiddleware(next http.Handler) http.Handler {
}) })
} }
func EnvCheck() error {
if os.Getenv("username") == "" {
return fmt.Errorf("Missing: username")
}
if os.Getenv("password") == "" {
return fmt.Errorf("Missing: password")
}
if os.Getenv("AUTH_TOTP_SECRET") == "" {
return fmt.Errorf("Missing: AUTH_TOTP_SECRET")
}
return nil
}
func RegisterRoutes(mux *http.ServeMux, db *sql.DB) { func RegisterRoutes(mux *http.ServeMux, db *sql.DB) {
svc := service.New(db) // ← create it here, once svc := service.New(db) // ← create it here, once
// ── Website (HTMX fragments) ────────────────────────────── // ── Website (HTMX fragments) ──────────────────────────────
mux.HandleFunc("/", website.Getsite()) mux.HandleFunc("/", website.Getsite())
mux.HandleFunc("/style", website.Getstylesheet()) mux.HandleFunc("/styles/main", website.GetstylesheetMain())
mux.HandleFunc("/styles/login", website.GetstylesheetLogin())
mux.HandleFunc("/styles/admin", website.GetstylesheetAdmin())
mux.Handle("/admin", middleware.RequireSession(website.GetAdmin()))
mux.HandleFunc("/login", website.GetAdminLogin())
mux.HandleFunc("POST /auth/login", website.LoginHandler())
mux.HandleFunc("/health/fragment", website.HealthFragment(svc)) mux.HandleFunc("/health/fragment", website.HealthFragment(svc))
mux.HandleFunc("/positions/fragment", website.PositionsFragment(svc)) mux.HandleFunc("/positions/fragment", website.PositionsFragment(svc))
mux.HandleFunc("/company/fragment", website.CompanyFragment(svc)) mux.HandleFunc("/company/fragment", website.CompanyFragment(svc))
@@ -47,24 +71,19 @@ func RegisterRoutes(mux *http.ServeMux, db *sql.DB) {
// ── API (JSON) ──────────────────────────────────────────── // ── API (JSON) ────────────────────────────────────────────
mux.HandleFunc("/health", svc.HealthHandler()) mux.HandleFunc("/health", svc.HealthHandler())
// Trades
mux.HandleFunc("POST /trade/add", svc.AddTradeHandler()) mux.HandleFunc("POST /trade/add", svc.AddTradeHandler())
mux.HandleFunc("GET /trade/list", svc.GetTradeListHandler()) mux.HandleFunc("GET /trade/list", svc.GetTradeListHandler())
//mux.HandleFunc("GET /trade/search", svc.SearchTradeHandler(svc)) //mux.HandleFunc("GET /trade/search", svc.SearchTradeHandler(svc))
// Positions
mux.HandleFunc("GET /positions/list", svc.GetPositionListHandler()) mux.HandleFunc("GET /positions/list", svc.GetPositionListHandler())
//mux.HandleFunc("GET /positions/closed/list", svc.GetClosedPositionListHandler(svc)) //mux.HandleFunc("GET /positions/closed/list", svc.GetClosedPositionListHandler(svc))
//mux.HandleFunc("GET /positions/closed/search", svc.SearchClosedPositionsHandler(svc)) //mux.HandleFunc("GET /positions/closed/search", svc.SearchClosedPositionsHandler(svc))
// Company
mux.HandleFunc("POST /company/add", svc.AddCompanyHandler()) mux.HandleFunc("POST /company/add", svc.AddCompanyHandler())
mux.HandleFunc("GET /company/list", svc.GetCompaniesHandler()) mux.HandleFunc("GET /company/list", svc.GetCompaniesHandler())
mux.HandleFunc("GET /company/revenue/categories", svc.GetCompanyRevenueCategories()) mux.HandleFunc("GET /company/revenue/categories", svc.GetCompanyRevenueCategories())
//mux.HandleFunc("POST /company/S-O/add", svc.AddSharesOutstandingHandler(svc)) //mux.HandleFunc("POST /company/S-O/add", svc.AddSharesOutstandingHandler(svc))
//mux.HandleFunc("GET /company/S-O/list", svc.GetSharesOutstandingHandler(svc)) //mux.HandleFunc("GET /company/S-O/list", svc.GetSharesOutstandingHandler(svc))
// Currency
mux.HandleFunc("GET /currency/list", svc.GetCurrenciesHandler()) mux.HandleFunc("GET /currency/list", svc.GetCurrenciesHandler())
mux.HandleFunc("POST /currency/add", svc.AddCurrencyHandler()) mux.HandleFunc("POST /currency/add", svc.AddCurrencyHandler())
// Revenue
mux.HandleFunc("POST /add/revenue/entry", svc.AddRevenueEntryHandler()) mux.HandleFunc("POST /add/revenue/entry", svc.AddRevenueEntryHandler())
mux.HandleFunc("POST /api/v1/revenue/add", svc.AddRevenueEntryHandler()) mux.HandleFunc("POST /api/v1/revenue/add", svc.AddRevenueEntryHandler())
} }
@@ -88,6 +107,15 @@ func main() {
log.Fatal("Failed to connect to database:", err) log.Fatal("Failed to connect to database:", err)
} }
err = godotenv.Load(".env")
if err != nil {
log.Fatal("Error loading .env file")
}
err = EnvCheck()
if err != nil {
log.Fatal("Env vars not found:", err)
}
database.InitDB(db) database.InitDB(db)
fmt.Println("Connected to SQLite database") fmt.Println("Connected to SQLite database")