+ Welcome back, {{.Username}} +
++ Here's what's happening with your server. +
++ Server Health +
++ Ping the server to check it's alive. +
+ + + + +diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..507dc0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# binary +sprintpadawan + +# sqlite db +app.db + +# env +.env + +# os +.DS_Store +Thumbs.db diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7dd4c55 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module sprintpadawan + +go 1.26.1 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-sqlite3 v1.14.42 // indirect + golang.org/x/crypto v0.50.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..97e89ce --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= diff --git a/lib/db.go b/lib/db.go new file mode 100644 index 0000000..288a515 --- /dev/null +++ b/lib/db.go @@ -0,0 +1,121 @@ +package lib + +import ( + "database/sql" + "log" + "time" + + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/bcrypt" +) + +var DB *sql.DB + +type User struct { + ID int + Username string + PasswordHash string +} + +// init sqlite db — always creates app.db at project root (run from root) +func InitDB() { + var err error + DB, err = sql.Open("sqlite3", "./app.db") + if err != nil { + log.Fatal(err) + } + + // make users table + userTable := ` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL + );` + _, err = DB.Exec(userTable) + if err != nil { + log.Fatal(err) + } + + // make sessions table + sessionTable := ` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + );` + _, err = DB.Exec(sessionTable) + if err != nil { + log.Fatal(err) + } +} + +// hash password +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) + return string(bytes), err +} + +// check password +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// create new user +func CreateUser(username, password string) error { + hash, err := HashPassword(password) + if err != nil { + return err + } + _, err = DB.Exec("INSERT INTO users (username, password_hash) VALUES (?, ?)", username, hash) + return err +} + +// get user by name +func GetUserByUsername(username string) (*User, error) { + row := DB.QueryRow("SELECT id, username, password_hash FROM users WHERE username = ?", username) + u := &User{} + err := row.Scan(&u.ID, &u.Username, &u.PasswordHash) + if err != nil { + return nil, err + } + return u, nil +} + +// create session token +func CreateSession(userID int) (string, error) { + sessionID := uuid.New().String() + expiresAt := time.Now().Add(24 * time.Hour) + + _, err := DB.Exec("INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)", sessionID, userID, expiresAt) + if err != nil { + return "", err + } + return sessionID, nil +} + +// get user from session +func GetUserFromSession(sessionID string) (*User, error) { + row := DB.QueryRow(` + SELECT u.id, u.username, u.password_hash + FROM users u + JOIN sessions s ON u.id = s.user_id + WHERE s.id = ? AND s.expires_at > ? + `, sessionID, time.Now()) + + u := &User{} + err := row.Scan(&u.ID, &u.Username, &u.PasswordHash) + if err != nil { + return nil, err + } + return u, nil +} + +// delete session +func DeleteSession(sessionID string) error { + _, err := DB.Exec("DELETE FROM sessions WHERE id = ?", sessionID) + return err +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7bc059f --- /dev/null +++ b/main.go @@ -0,0 +1,166 @@ +package main + +import ( + "context" + "encoding/json" + "html/template" + "log" + "net/http" + "path/filepath" + "time" + + "sprintpadawan/lib" +) + +type PingResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +var templates *template.Template + +type contextKey string + +const userKey contextKey = "user" + +func main() { + lib.InitDB() + + var err error + templates, err = template.ParseGlob(filepath.Join("templates", "*.html")) + if err != nil { + log.Fatalf("failed to parse templates: %v", err) + } + + mux := http.NewServeMux() + + // serve static assets + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + + // public routes + mux.HandleFunc("/login", handleLogin) + mux.HandleFunc("/register", handleRegister) + mux.HandleFunc("/logout", handleLogout) + + // private routes + mux.HandleFunc("/", requireAuth(handleIndex)) + mux.HandleFunc("/api/ping", requireAuth(handlePing)) + mux.HandleFunc("/api/ping-partial", requireAuth(handlePingPartial)) + + addr := ":8080" + log.Printf("Starting SprintPadawan server on http://localhost%s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("server error: %v", err) + } +} + +func requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session_token") + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + user, err := lib.GetUserFromSession(cookie.Value) + if err != nil || user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + ctx := context.WithValue(r.Context(), userKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + } +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + templates.ExecuteTemplate(w, "login.html", nil) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + user, err := lib.GetUserByUsername(username) + if err != nil || !lib.CheckPasswordHash(password, user.PasswordHash) { + templates.ExecuteTemplate(w, "login.html", map[string]string{"Error": "Invalid credentials"}) + return + } + + sessionID, _ := lib.CreateSession(user.ID) + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: sessionID, + Expires: time.Now().Add(24 * time.Hour), + HttpOnly: true, + Path: "/", + }) + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + templates.ExecuteTemplate(w, "register.html", nil) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + confirm := r.FormValue("confirm_password") + + if password != confirm { + templates.ExecuteTemplate(w, "register.html", map[string]string{"Error": "Passwords do not match"}) + return + } + + err := lib.CreateUser(username, password) + if err != nil { + templates.ExecuteTemplate(w, "register.html", map[string]string{"Error": "Username taken"}) + return + } + + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func handleLogout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session_token") + if err == nil { + lib.DeleteSession(cookie.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: "", + Expires: time.Now().Add(-1 * time.Hour), + Path: "/", + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + user := r.Context().Value(userKey).(*lib.User) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := templates.ExecuteTemplate(w, "index.html", user); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } +} + +func handlePing(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := PingResponse{Status: "ok", Message: "pong"} + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("json encode error: %v", err) + } +} + +func handlePingPartial(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := templates.ExecuteTemplate(w, "ping_result.html", PingResponse{Status: "ok", Message: "pong"}); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } +} diff --git a/static/styles/main.css b/static/styles/main.css new file mode 100644 index 0000000..468d465 --- /dev/null +++ b/static/styles/main.css @@ -0,0 +1,873 @@ +/* reset */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* tokens */ +:root { + --bg: #0a0a0f; + --bg-surface: rgba(255, 255, 255, 0.04); + --bg-surface-hover: rgba(255, 255, 255, 0.07); + --border: rgba(255, 255, 255, 0.08); + --border-accent: rgba(139, 92, 246, 0.4); + + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #475569; + + --accent: #8b5cf6; + --accent-hover: #7c3aed; + --accent-glow: rgba(139, 92, 246, 0.25); + + --success-bg: rgba(16, 185, 129, 0.08); + --success-border: rgba(16, 185, 129, 0.25); + --success-text: #34d399; + --success-label: #6ee7b7; + + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 20px; + + --shadow-card: 0 0 0 1px var(--border), 0 24px 48px rgba(0, 0, 0, 0.4); + --shadow-btn: 0 0 20px var(--accent-glow); + + --font: + "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + --font-mono: + "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace; +} + +/* base */ +html { + color-scheme: dark; +} + +body { + font-family: var(--font); + background-color: var(--bg); + color: var(--text-primary); + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + line-height: 1.6; + + /* subtle grid bg */ + background-image: + radial-gradient( + ellipse 80% 60% at 50% -10%, + rgba(139, 92, 246, 0.12), + transparent + ), + linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); + background-size: + auto, + 40px 40px, + 40px 40px; +} + +/* card */ +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 3rem 2.75rem; + max-width: 480px; + width: 100%; + text-align: center; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + position: relative; + overflow: hidden; +} + +/* app shell body — reset centering, fill viewport */ +body.app-body { + display: block; + padding: 0; + min-height: 100vh; + height: 100vh; + overflow: hidden; +} + +/* top glow line */ +.card::before { + content: ""; + position: absolute; + top: 0; + left: 10%; + right: 10%; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0.6; +} + +/* logo / wordmark */ +.logo { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1.25rem; +} + +.logo-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--accent), #6366f1); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + box-shadow: 0 0 16px var(--accent-glow); +} + +/* heading */ +h1 { + font-size: 1.875rem; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--text-primary); + line-height: 1.2; +} + +h1 span { + background: linear-gradient(135deg, #c4b5fd, var(--accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.subtitle { + font-size: 0.9rem; + color: var(--text-secondary); + margin-top: 0.4rem; + margin-bottom: 2.25rem; +} + +/* divider */ +.divider { + height: 1px; + background: var(--border); + margin: 2rem 0; +} + +/* button */ +.ping-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: linear-gradient(135deg, var(--accent), #6366f1); + color: #fff; + font-family: var(--font); + font-size: 0.9rem; + font-weight: 600; + padding: 0.65rem 1.6rem; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + letter-spacing: 0.01em; + transition: + opacity 0.2s ease, + transform 0.15s ease, + box-shadow 0.2s ease; + box-shadow: var(--shadow-btn); + position: relative; + overflow: hidden; +} + +.ping-btn::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(rgba(255, 255, 255, 0.12), transparent); + pointer-events: none; +} + +.ping-btn:hover { + opacity: 0.9; + box-shadow: 0 0 32px var(--accent-glow); + transform: translateY(-1px); +} + +.ping-btn:active { + transform: scale(0.97) translateY(0); +} + +.ping-btn.htmx-request { + opacity: 0.6; + cursor: wait; + transform: none; + box-shadow: none; +} + +/* ping result area */ +#ping-result { + margin-top: 1.75rem; + min-height: 3rem; + display: flex; + align-items: center; + justify-content: center; +} + +/* result box */ +.result-box { + background: var(--success-bg); + border: 1px solid var(--success-border); + border-radius: var(--radius-md); + padding: 0.85rem 1.5rem; + width: 100%; + text-align: left; + display: flex; + align-items: center; + gap: 1rem; + animation: fadeSlideIn 0.25s ease forwards; +} + +.result-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success-text); + box-shadow: 0 0 8px var(--success-text); + flex-shrink: 0; + animation: pulse 2s ease infinite; +} + +.result-content { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.result-label { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--success-label); +} + +.result-message { + font-family: var(--font-mono); + font-size: 0.9rem; + color: var(--success-text); +} + +/* stats row */ +.stats { + display: flex; + gap: 1rem; + margin-top: 2rem; +} + +.stat { + flex: 1; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.85rem 1rem; + transition: + background 0.2s ease, + border-color 0.2s ease; +} + +.stat:hover { + background: var(--bg-surface-hover); + border-color: var(--border-accent); +} + +.stat-value { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + font-family: var(--font-mono); +} + +.stat-label { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.1rem; +} + +/* footer */ +footer { + margin-top: 2.5rem; + font-size: 0.78rem; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 0.4rem; +} + +footer a { + color: var(--text-secondary); + text-decoration: none; + transition: color 0.2s; +} + +footer a:hover { + color: var(--accent); +} + +/* animations */ +@keyframes fadeSlideIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +/* scrollbar */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 99px; +} + +/* ============================================================ + AUTH PAGES (login / register) + ============================================================ */ + +/* body variant — keeps the grid bg from body, just re-centers */ +.auth-body { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; +} + +/* narrower card */ +.auth-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 2.5rem 2.25rem; + max-width: 420px; + width: 100%; + text-align: center; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + position: relative; + overflow: hidden; +} + +.auth-card::before { + content: ""; + position: absolute; + top: 0; + left: 10%; + right: 10%; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0.6; +} + +/* logo row */ +.auth-logo { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-bottom: 1.25rem; +} + +/* headings */ +.auth-title { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--text-primary); + line-height: 1.2; + margin-bottom: 0.35rem; +} + +.auth-subtitle { + font-size: 0.875rem; + color: var(--text-secondary); + margin-bottom: 2rem; +} + +/* form layout */ +.auth-form { + display: flex; + flex-direction: column; + gap: 1.25rem; + text-align: left; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.form-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.form-input { + width: 100%; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.7rem 1rem; + color: var(--text-primary); + font-family: var(--font); + font-size: 0.95rem; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.form-input::placeholder { + color: var(--text-muted); +} + +.form-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +/* error message box */ +.form-error { + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.25); + border-radius: var(--radius-md); + padding: 0.7rem 1rem; + font-size: 0.85rem; + color: #f87171; + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* full-width primary button */ +.btn-primary { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: linear-gradient(135deg, var(--accent), #6366f1); + color: #fff; + font-family: var(--font); + font-size: 0.95rem; + font-weight: 600; + padding: 0.75rem; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + letter-spacing: 0.01em; + transition: + opacity 0.2s ease, + transform 0.15s ease, + box-shadow 0.2s ease; + box-shadow: var(--shadow-btn); + position: relative; + overflow: hidden; +} + +.btn-primary::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(rgba(255, 255, 255, 0.12), transparent); + pointer-events: none; +} + +.btn-primary:hover { + opacity: 0.9; + box-shadow: 0 0 32px var(--accent-glow); + transform: translateY(-1px); +} + +.btn-primary:active { + transform: scale(0.97) translateY(0); +} + +/* footer link line below the card */ +.auth-footer { + margin-top: 1.5rem; + font-size: 0.85rem; + color: var(--text-muted); + text-align: center; +} + +.auth-footer a { + color: var(--accent); + text-decoration: none; + transition: opacity 0.2s; +} + +.auth-footer a:hover { + opacity: 0.8; +} + +/* "or" divider */ +.auth-divider { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--text-muted); + font-size: 0.8rem; +} + +.auth-divider::before, +.auth-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} + +/* ============================================================ + HOME / DASHBOARD — app shell + ============================================================ */ + +.app-shell { + display: grid; + grid-template-columns: 240px 1fr; + grid-template-rows: 1fr; + height: 100vh; + width: 100%; + overflow: hidden; +} + +/* sidebar */ +.sidebar { + background: rgba(255, 255, 255, 0.02); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 1.5rem 1rem; + overflow-y: auto; +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.75rem; + margin-bottom: 1.5rem; +} + +.sidebar-logo .logo-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--accent), #6366f1); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + box-shadow: 0 0 16px var(--accent-glow); + flex-shrink: 0; +} + +.sidebar-logo .logo-text { + font-weight: 700; + font-size: 1rem; + background: linear-gradient(135deg, #c4b5fd, var(--accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* nav links */ +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-md); + color: var(--text-secondary); + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.15s; + border: 1px solid transparent; +} + +.nav-item:hover { + background: var(--bg-surface-hover); + color: var(--text-primary); +} + +.nav-item.active { + background: rgba(139, 92, 246, 0.12); + color: var(--accent); + border-color: rgba(139, 92, 246, 0.2); +} + +.nav-item svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* sidebar bottom section */ +.sidebar-footer { + margin-top: auto; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.user-row { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-md); +} + +.user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), #6366f1); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.user-info { + display: flex; + flex-direction: column; + gap: 0.1rem; + overflow: hidden; + flex: 1; +} + +.user-name { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-role { + font-size: 0.72rem; + color: var(--text-muted); +} + +.sign-out-btn { + width: 100%; + margin-top: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.75rem; + border-radius: var(--radius-md); + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 0.85rem; + font-family: var(--font); + cursor: pointer; + transition: all 0.15s; +} + +.sign-out-btn:hover { + border-color: rgba(239, 68, 68, 0.4); + color: #f87171; + background: rgba(239, 68, 68, 0.06); +} + +/* main content area */ +.main-content { + display: flex; + flex-direction: column; + overflow-y: auto; + background: var(--bg); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 2rem; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.topbar-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.page-content { + padding: 2rem; + flex: 1; + max-width: 900px; + width: 100%; +} + +/* welcome hero */ +.welcome-hero { + padding: 2rem; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + margin-bottom: 2rem; + position: relative; + overflow: hidden; +} + +.welcome-hero::before { + content: ""; + position: absolute; + top: 0; + left: 10%; + right: 10%; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0.6; +} + +.welcome-greeting { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 0.35rem; +} + +.welcome-greeting span { + background: linear-gradient(135deg, #c4b5fd, var(--accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.welcome-sub { + color: var(--text-secondary); + font-size: 0.9rem; +} + +/* dashboard stat cards grid */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 2rem; +} + +.dash-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + padding: 1.25rem 1.5rem; + text-align: left; + position: relative; + overflow: hidden; + transition: + background 0.2s ease, + border-color 0.2s ease; +} + +.dash-card:hover { + background: var(--bg-surface-hover); + border-color: var(--border-accent); +} + +.dash-card-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.dash-card-value { + font-size: 1.5rem; + font-weight: 700; + font-family: var(--font-mono); + color: var(--text-primary); +} + +/* ============================================================ + RESPONSIVE + ============================================================ */ + +@media (max-width: 768px) { + .app-shell { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + height: auto; + min-height: 100vh; + overflow-y: auto; + } + + .sidebar { + border-right: none; + border-bottom: 1px solid var(--border); + padding: 1rem; + } + + .sidebar-nav { + flex-direction: row; + flex-wrap: wrap; + gap: 0.25rem; + } + + .main-content { + overflow-y: unset; + } + + .dashboard-grid { + grid-template-columns: 1fr; + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..e74bb2b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,150 @@ + + +
+ + ++ Welcome back, {{.Username}} +
++ Here's what's happening with your server. +
++ Server Health +
++ Ping the server to check it's alive. +
+ + + + +Sign in to your account
+ + {{if .Error}} +Create your account
+ + {{if .Error}} +