admin panel, login page with auth session.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
data/**
|
||||
.env
|
||||
BIN
Portifolio
BIN
Portifolio
Binary file not shown.
9
go.mod
9
go.mod
@@ -2,4 +2,11 @@ module Portifolio
|
||||
|
||||
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
6
go.sum
@@ -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/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=
|
||||
|
||||
58
internal/middleware/session.go
Normal file
58
internal/middleware/session.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
package website
|
||||
|
||||
import (
|
||||
"Portifolio/internal/middleware"
|
||||
"Portifolio/internal/model"
|
||||
"Portifolio/internal/service"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/FuLygon/go-totp/v2"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
@@ -21,13 +27,136 @@ func Getsite() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
//go:embed static/styles.css
|
||||
var styleCSS []byte
|
||||
//go:embed static/login.html
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
444
internal/website/static/admin.css
Normal file
444
internal/website/static/admin.css
Normal 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; }
|
||||
}
|
||||
582
internal/website/static/admin.html
Normal file
582
internal/website/static/admin.html
Normal 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 · 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>
|
||||
@@ -5,14 +5,15 @@
|
||||
<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="/style">
|
||||
<link rel="stylesheet" href="/styles/main">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<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>
|
||||
<h1>Investment<br/><span>Portfolio</span></h1>
|
||||
<p class="header-sub">Equity positions · Personal research</p>
|
||||
|
||||
224
internal/website/static/login.css
Normal file
224
internal/website/static/login.css
Normal 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;
|
||||
}
|
||||
67
internal/website/static/login.html
Normal file
67
internal/website/static/login.html
Normal 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 · 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">© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<p class="corner-mark">SF — 2026</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
:root {
|
||||
--bg: #f4f4f4;
|
||||
--bg: #f5efe7;;
|
||||
--surface: #efefef;
|
||||
--surface2:#e0e0e0;
|
||||
--border: #8c8c8c;
|
||||
|
||||
40
main.go
40
main.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"Portifolio/internal/database"
|
||||
"Portifolio/internal/middleware"
|
||||
"Portifolio/internal/service"
|
||||
"Portifolio/internal/website"
|
||||
"database/sql"
|
||||
@@ -9,6 +10,9 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
_ "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) {
|
||||
svc := service.New(db) // ← create it here, once
|
||||
|
||||
// ── Website (HTMX fragments) ──────────────────────────────
|
||||
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("/positions/fragment", website.PositionsFragment(svc))
|
||||
mux.HandleFunc("/company/fragment", website.CompanyFragment(svc))
|
||||
@@ -47,24 +71,19 @@ func RegisterRoutes(mux *http.ServeMux, db *sql.DB) {
|
||||
|
||||
// ── API (JSON) ────────────────────────────────────────────
|
||||
mux.HandleFunc("/health", svc.HealthHandler())
|
||||
// Trades
|
||||
mux.HandleFunc("POST /trade/add", svc.AddTradeHandler())
|
||||
mux.HandleFunc("GET /trade/list", svc.GetTradeListHandler())
|
||||
//mux.HandleFunc("GET /trade/search", svc.SearchTradeHandler(svc))
|
||||
// Positions
|
||||
mux.HandleFunc("GET /positions/list", svc.GetPositionListHandler())
|
||||
//mux.HandleFunc("GET /positions/closed/list", svc.GetClosedPositionListHandler(svc))
|
||||
//mux.HandleFunc("GET /positions/closed/search", svc.SearchClosedPositionsHandler(svc))
|
||||
// Company
|
||||
mux.HandleFunc("POST /company/add", svc.AddCompanyHandler())
|
||||
mux.HandleFunc("GET /company/list", svc.GetCompaniesHandler())
|
||||
mux.HandleFunc("GET /company/revenue/categories", svc.GetCompanyRevenueCategories())
|
||||
//mux.HandleFunc("POST /company/S-O/add", svc.AddSharesOutstandingHandler(svc))
|
||||
//mux.HandleFunc("GET /company/S-O/list", svc.GetSharesOutstandingHandler(svc))
|
||||
// Currency
|
||||
mux.HandleFunc("GET /currency/list", svc.GetCurrenciesHandler())
|
||||
mux.HandleFunc("POST /currency/add", svc.AddCurrencyHandler())
|
||||
// Revenue
|
||||
mux.HandleFunc("POST /add/revenue/entry", 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)
|
||||
}
|
||||
|
||||
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)
|
||||
fmt.Println("Connected to SQLite database")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user