diff --git a/.env.example b/.env.example index 8d06ec0..7d6119b 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,11 @@ SESSION_SECRET=change-me-use-openssl-rand-hex-32 # Email address that receives hire form submissions. # Requires a working local MTA (OpenSMTPD) listening on localhost:25. CONTACT_EMAIL=hire@ridgwaysystems.org + +# ------------------------------------------------------------------ +# Service checker +# ------------------------------------------------------------------ + +# How often (in minutes) to HTTP-check services with a check_url in status.json. +# Default: 5 +CHECK_INTERVAL=5 diff --git a/cmd/server/main.go b/cmd/server/main.go index ef24796..d26957b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,12 +5,17 @@ import ( "log" "net/http" "os" + "path/filepath" + "strconv" + "time" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/styles" "ridgwaysystems.org/website/internal/blog" + "ridgwaysystems.org/website/internal/checker" "ridgwaysystems.org/website/internal/handler" + "ridgwaysystems.org/website/internal/newsletter" ) func main() { @@ -28,11 +33,21 @@ func main() { log.Fatal("store:", err) } - h := handler.New(store, dataDir) + news, err := newsletter.NewStore(filepath.Join(dataDir, "subscribers.json")) + if err != nil { + log.Fatal("newsletter store:", err) + } + + h := handler.New(store, news, dataDir) + + // Start service auto-checker + checkInterval := checkerInterval() + checker.Start(dataDir, checkInterval) + log.Printf("service checker running every %s", checkInterval) mux := http.NewServeMux() - // Static files (includes robots.txt via static/robots.txt) + // Static files mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) // robots.txt at root @@ -48,9 +63,12 @@ func main() { mux.HandleFunc("GET /infrastructure", h.Infrastructure) mux.HandleFunc("GET /status", h.Status) mux.HandleFunc("GET /about", h.About) + mux.HandleFunc("GET /uses", h.Uses) + mux.HandleFunc("GET /projects", h.Projects) mux.HandleFunc("GET /hire", h.Hire) mux.HandleFunc("POST /hire", h.HirePost) mux.HandleFunc("GET /resume", h.Resume) + mux.HandleFunc("POST /newsletter", h.NewsletterPost) mux.HandleFunc("GET /sitemap.xml", h.Sitemap) // Admin routes (auth handled per-handler) @@ -66,19 +84,26 @@ func main() { log.Fatal(http.ListenAndServe(":"+port, srv)) } +// checkerInterval returns the status check interval from CHECK_INTERVAL env (minutes). +func checkerInterval() time.Duration { + if s := os.Getenv("CHECK_INTERVAL"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n > 0 { + return time.Duration(n) * time.Minute + } + } + return 5 * time.Minute +} + // generateSyntaxCSS writes a chroma CSS file with light and dark themes. func generateSyntaxCSS(path string) error { formatter := chromahtml.New(chromahtml.WithClasses(true)) var buf bytes.Buffer - - // Light theme (github) buf.WriteString("/* Auto-generated by chroma at server startup. Do not edit. */\n\n") if err := formatter.WriteCSS(&buf, styles.Get("github")); err != nil { return err } - // Dark theme wrapped in prefers-color-scheme media query buf.WriteString("\n@media (prefers-color-scheme: dark) {\n") var dark bytes.Buffer if err := formatter.WriteCSS(&dark, styles.Get("github-dark")); err != nil { diff --git a/data/status.json b/data/status.json index fe4d95b..0dbe53d 100644 --- a/data/status.json +++ b/data/status.json @@ -4,12 +4,14 @@ { "name": "Web (httpd)", "description": "ridgwaysystems.org", + "check_url": "https://ridgwaysystems.org", "status": "up" }, { "name": "Gitea", "description": "git.ridgwaysystems.org", "url": "https://git.ridgwaysystems.org", + "check_url": "https://git.ridgwaysystems.org", "status": "up" }, { diff --git a/internal/checker/checker.go b/internal/checker/checker.go new file mode 100644 index 0000000..6bfbdb4 --- /dev/null +++ b/internal/checker/checker.go @@ -0,0 +1,88 @@ +// Package checker periodically HTTP-checks services and updates status.json. +package checker + +import ( + "log" + "net/http" + "path/filepath" + "time" + + "ridgwaysystems.org/website/internal/status" +) + +// Start launches the background checker. It checks services every interval +// and updates status.json in dataDir. Services without a CheckURL are skipped. +func Start(dataDir string, interval time.Duration) { + go run(dataDir, interval) +} + +func run(dataDir string, interval time.Duration) { + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return http.ErrUseLastResponse + } + return nil + }, + } + path := filepath.Join(dataDir, "status.json") + + for { + check(client, path) + time.Sleep(interval) + } +} + +func check(client *http.Client, path string) { + page, err := status.Load(path) + if err != nil { + log.Printf("checker: load %s: %v", path, err) + return + } + + changed := false + for i, svc := range page.Services { + if svc.CheckURL == "" { + continue + } + + prev := svc.Status + newStatus := probe(client, svc.CheckURL) + if newStatus != prev { + log.Printf("checker: %s %s → %s", svc.Name, prev, newStatus) + page.Services[i].Status = newStatus + changed = true + } + } + + if changed { + if err := status.Save(path, page); err != nil { + log.Printf("checker: save %s: %v", path, err) + } + } else { + // Still update last_checked timestamp so the status page shows freshness + page.LastChecked = time.Now().UTC() + if err := status.Save(path, page); err != nil { + log.Printf("checker: save %s: %v", path, err) + } + } +} + +func probe(client *http.Client, url string) string { + resp, err := client.Get(url) + if err != nil { + return "down" + } + defer resp.Body.Close() + + switch { + case resp.StatusCode < 400: + return "up" + case resp.StatusCode >= 500: + return "down" + default: + // 4xx could mean the service is up but the URL is wrong; treat as degraded + return "degraded" + } +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 1d77841..78408c6 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -12,6 +12,7 @@ import ( "time" "ridgwaysystems.org/website/internal/blog" + "ridgwaysystems.org/website/internal/newsletter" "ridgwaysystems.org/website/internal/status" ) @@ -63,6 +64,13 @@ func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) { case path == "/admin/uploads": h.requireAuth(h.adminUploads)(w, r) + case path == "/admin/newsletter": + if r.Method == http.MethodPost { + h.requireAuth(h.adminNewsletterDelete)(w, r) + } else { + h.requireAuth(h.adminNewsletter)(w, r) + } + default: h.renderErr(w, http.StatusNotFound, "Admin page not found.") } @@ -374,6 +382,40 @@ func (h *Handler) adminUploads(w http.ResponseWriter, r *http.Request) { h.render(w, "admin-uploads", uploadsData{Files: files, Flash: r.URL.Query().Get("flash")}) } +// --- Newsletter admin --- + +type adminNewsletterData struct { + Subscribers []newsletter.Subscriber + Flash string + Count int +} + +func (h *Handler) adminNewsletter(w http.ResponseWriter, r *http.Request) { + subs, err := h.news.All() + if err != nil { + h.renderErr(w, http.StatusInternalServerError, "Could not load subscribers.") + return + } + h.render(w, "admin-newsletter", adminNewsletterData{ + Subscribers: subs, + Count: len(subs), + Flash: r.URL.Query().Get("flash"), + }) +} + +func (h *Handler) adminNewsletterDelete(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderErr(w, http.StatusBadRequest, "Bad form data.") + return + } + email := r.FormValue("email") + if err := h.news.Remove(email); err != nil { + h.renderErr(w, http.StatusInternalServerError, "Remove failed: "+err.Error()) + return + } + http.Redirect(w, r, "/admin/newsletter?flash=Removed", http.StatusSeeOther) +} + // sanitizeSlug ensures a slug is filesystem-safe. func sanitizeSlug(s string) string { s = strings.ToLower(strings.TrimSpace(s)) diff --git a/internal/handler/csrf.go b/internal/handler/csrf.go new file mode 100644 index 0000000..466412f --- /dev/null +++ b/internal/handler/csrf.go @@ -0,0 +1,37 @@ +package handler + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" +) + +// csrfToken returns an HMAC token valid for the current and previous hour. +// Reuses the session secret so no additional secret is required. +func csrfToken() string { + return csrfTokenForTime(time.Now().UTC()) +} + +func csrfTokenForTime(t time.Time) string { + bucket := t.Truncate(time.Hour).Unix() + mac := hmac.New(sha256.New, sessionSecret()) + mac.Write([]byte(fmt.Sprintf("csrf:%d", bucket))) + return hex.EncodeToString(mac.Sum(nil)) +} + +// csrfValid returns true if token matches the current or previous hour's token. +func csrfValid(token string) bool { + if token == "" { + return false + } + now := time.Now().UTC() + for _, t := range []time.Time{now, now.Add(-time.Hour)} { + expected := csrfTokenForTime(t) + if hmac.Equal([]byte(token), []byte(expected)) { + return true + } + } + return false +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index d5d3a6f..25b0ffe 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -8,28 +8,35 @@ import ( "net/http" "os" "path/filepath" + "time" "ridgwaysystems.org/website/internal/blog" + "ridgwaysystems.org/website/internal/newsletter" + "ridgwaysystems.org/website/internal/ratelimit" ) // Handler holds shared dependencies for all HTTP handlers. type Handler struct { store *blog.Store + news *newsletter.Store dataDir string templates map[string]*template.Template siteURL string contactEmail string devMode bool + postLimit *ratelimit.Limiter // rate-limits contact + newsletter POSTs } // New creates a Handler. dataDir is the path to the data/ directory. -func New(store *blog.Store, dataDir string) *Handler { +func New(store *blog.Store, news *newsletter.Store, dataDir string) *Handler { h := &Handler{ store: store, + news: news, dataDir: dataDir, siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"), contactEmail: getenv("CONTACT_EMAIL", "hire@ridgwaysystems.org"), devMode: os.Getenv("DEV") == "1", + postLimit: ratelimit.New(10*time.Minute, 5), } if !h.devMode { h.templates = mustLoadTemplates() @@ -74,12 +81,15 @@ func mustLoadTemplates() map[string]*template.Template { {"about", "templates/about.html"}, {"hire", "templates/hire.html"}, {"resume", "templates/resume.html"}, + {"uses", "templates/uses.html"}, + {"projects", "templates/projects.html"}, {"error", "templates/error.html"}, {"admin-login", "templates/admin/login.html"}, {"admin-dashboard", "templates/admin/dashboard.html"}, {"admin-editor", "templates/admin/editor.html"}, {"admin-status", "templates/admin/status.html"}, {"admin-uploads", "templates/admin/uploads.html"}, + {"admin-newsletter", "templates/admin/newsletter.html"}, } for _, p := range pages { diff --git a/internal/handler/public.go b/internal/handler/public.go index 9ce8e34..0dd7d18 100644 --- a/internal/handler/public.go +++ b/internal/handler/public.go @@ -182,31 +182,57 @@ func (h *Handler) Resume(w http.ResponseWriter, r *http.Request) { h.render(w, "resume", nil) } +func (h *Handler) Uses(w http.ResponseWriter, r *http.Request) { + h.render(w, "uses", nil) +} + +func (h *Handler) Projects(w http.ResponseWriter, r *http.Request) { + h.render(w, "projects", nil) +} + // --- Hire / Contact --- type hireData struct { - Name string - Email string - Company string - Message string - Error string - Success bool + Name string + Email string + Company string + Message string + Error string + Success bool + CSRFToken string } func (h *Handler) Hire(w http.ResponseWriter, r *http.Request) { - h.render(w, "hire", hireData{}) + h.render(w, "hire", hireData{CSRFToken: csrfToken()}) } func (h *Handler) HirePost(w http.ResponseWriter, r *http.Request) { + // Rate limit + if !h.postLimit.Allow(r.RemoteAddr) { + h.renderErr(w, http.StatusTooManyRequests, "Too many requests. Please try again later.") + return + } if err := r.ParseForm(); err != nil { h.renderErr(w, http.StatusBadRequest, "Bad form data.") return } + // Honeypot: bots fill hidden fields, humans don't + if r.FormValue("website") != "" { + // Silently succeed to not reveal the check + h.render(w, "hire", hireData{Success: true}) + return + } + // CSRF + if !csrfValid(r.FormValue("csrf_token")) { + h.renderErr(w, http.StatusForbidden, "Invalid or expired form token. Please reload the page and try again.") + return + } d := hireData{ - Name: strings.TrimSpace(r.FormValue("name")), - Email: strings.TrimSpace(r.FormValue("email")), - Company: strings.TrimSpace(r.FormValue("company")), - Message: strings.TrimSpace(r.FormValue("message")), + Name: strings.TrimSpace(r.FormValue("name")), + Email: strings.TrimSpace(r.FormValue("email")), + Company: strings.TrimSpace(r.FormValue("company")), + Message: strings.TrimSpace(r.FormValue("message")), + CSRFToken: csrfToken(), } if d.Name == "" || d.Email == "" || d.Message == "" { d.Error = "Name, email, and message are required." @@ -234,6 +260,44 @@ func (h *Handler) HirePost(w http.ResponseWriter, r *http.Request) { h.render(w, "hire", d) } +// --- Newsletter --- + +type newsletterData struct { + Error string + Success bool +} + +func (h *Handler) NewsletterPost(w http.ResponseWriter, r *http.Request) { + // Rate limit + if !h.postLimit.Allow(r.RemoteAddr) { + http.Error(w, "Too many requests", http.StatusTooManyRequests) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + // Honeypot + if r.FormValue("url") != "" { + http.Redirect(w, r, r.Referer(), http.StatusSeeOther) + return + } + email := strings.ToLower(strings.TrimSpace(r.FormValue("email"))) + if email == "" || !strings.Contains(email, "@") { + http.Redirect(w, r, r.Referer()+"?subscribe=invalid", http.StatusSeeOther) + return + } + if _, err := h.news.Add(email); err != nil { + log.Printf("newsletter add %s: %v", email, err) + } + // Redirect back with success param regardless (don't confirm existence) + ref := r.Referer() + if ref == "" { + ref = "/" + } + http.Redirect(w, r, ref+"?subscribe=ok", http.StatusSeeOther) +} + // --- Sitemap --- type urlset struct { @@ -256,7 +320,9 @@ func (h *Handler) Sitemap(w http.ResponseWriter, r *http.Request) { {Loc: h.siteURL + "/", Freq: "weekly", Prio: "1.0"}, {Loc: h.siteURL + "/blog", Freq: "weekly", Prio: "0.9"}, {Loc: h.siteURL + "/hire", Freq: "monthly", Prio: "0.9"}, - {Loc: h.siteURL + "/resume", Freq: "monthly", Prio: "0.7"}, + {Loc: h.siteURL + "/resume", Freq: "monthly", Prio: "0.8"}, + {Loc: h.siteURL + "/projects", Freq: "monthly", Prio: "0.7"}, + {Loc: h.siteURL + "/uses", Freq: "monthly", Prio: "0.6"}, {Loc: h.siteURL + "/infrastructure", Freq: "monthly", Prio: "0.7"}, {Loc: h.siteURL + "/status", Freq: "daily", Prio: "0.6"}, {Loc: h.siteURL + "/about", Freq: "monthly", Prio: "0.5"}, diff --git a/internal/newsletter/newsletter.go b/internal/newsletter/newsletter.go new file mode 100644 index 0000000..db0f886 --- /dev/null +++ b/internal/newsletter/newsletter.go @@ -0,0 +1,111 @@ +// Package newsletter manages email subscriber storage as a flat JSON file. +package newsletter + +import ( + "encoding/json" + "errors" + "os" + "strings" + "sync" + "time" +) + +// Subscriber holds a single email subscription. +type Subscriber struct { + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +// Store manages subscribers persisted to a JSON file. +type Store struct { + mu sync.RWMutex + path string +} + +// NewStore returns a Store backed by path, creating the file if needed. +func NewStore(path string) (*Store, error) { + s := &Store{path: path} + // Create empty file if it doesn't exist + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := s.save(nil); err != nil { + return nil, err + } + } + return s, nil +} + +// All returns all current subscribers. +func (s *Store) All() ([]Subscriber, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.load() +} + +// Add adds an email if it isn't already subscribed. Returns false if duplicate. +func (s *Store) Add(email string) (bool, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return false, errors.New("email is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + subs, err := s.load() + if err != nil { + return false, err + } + for _, sub := range subs { + if sub.Email == email { + return false, nil // already subscribed + } + } + subs = append(subs, Subscriber{Email: email, CreatedAt: time.Now().UTC()}) + return true, s.save(subs) +} + +// Remove unsubscribes an email address. +func (s *Store) Remove(email string) error { + email = strings.ToLower(strings.TrimSpace(email)) + + s.mu.Lock() + defer s.mu.Unlock() + + subs, err := s.load() + if err != nil { + return err + } + filtered := subs[:0] + for _, sub := range subs { + if sub.Email != email { + filtered = append(filtered, sub) + } + } + return s.save(filtered) +} + +func (s *Store) load() ([]Subscriber, error) { + raw, err := os.ReadFile(s.path) + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, nil + } + var subs []Subscriber + if err := json.Unmarshal(raw, &subs); err != nil { + return nil, err + } + return subs, nil +} + +func (s *Store) save(subs []Subscriber) error { + if subs == nil { + subs = []Subscriber{} + } + raw, err := json.MarshalIndent(subs, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, raw, 0600) +} diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go new file mode 100644 index 0000000..d065af2 --- /dev/null +++ b/internal/ratelimit/ratelimit.go @@ -0,0 +1,91 @@ +// Package ratelimit provides a simple in-memory per-IP sliding-window rate limiter. +package ratelimit + +import ( + "net" + "net/http" + "sync" + "time" +) + +// Limiter tracks request counts per IP within a sliding window. +type Limiter struct { + mu sync.Mutex + entries map[string][]time.Time + window time.Duration + max int +} + +// New creates a Limiter that allows at most max requests per window per IP. +func New(window time.Duration, max int) *Limiter { + l := &Limiter{ + entries: make(map[string][]time.Time), + window: window, + max: max, + } + go l.cleanup() + return l +} + +// Allow returns true if the IP is within its rate limit, recording the attempt. +func (l *Limiter) Allow(ip string) bool { + now := time.Now() + cutoff := now.Add(-l.window) + + l.mu.Lock() + defer l.mu.Unlock() + + times := l.entries[ip] + recent := times[:0] + for _, t := range times { + if t.After(cutoff) { + recent = append(recent, t) + } + } + + if len(recent) >= l.max { + l.entries[ip] = recent + return false + } + + l.entries[ip] = append(recent, now) + return true +} + +// Middleware wraps a handler, rejecting over-limit requests with 429. +func (l *Limiter) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + ip = r.RemoteAddr + } + if !l.Allow(ip) { + http.Error(w, "Too many requests. Please wait and try again.", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) +} + +// cleanup periodically removes expired entries to prevent unbounded growth. +func (l *Limiter) cleanup() { + for { + time.Sleep(5 * time.Minute) + cutoff := time.Now().Add(-l.window) + l.mu.Lock() + for ip, times := range l.entries { + recent := times[:0] + for _, t := range times { + if t.After(cutoff) { + recent = append(recent, t) + } + } + if len(recent) == 0 { + delete(l.entries, ip) + } else { + l.entries[ip] = recent + } + } + l.mu.Unlock() + } +} diff --git a/internal/status/status.go b/internal/status/status.go index 77266ad..f51a422 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -4,15 +4,19 @@ package status import ( "encoding/json" "os" + "sync" "time" ) +var mu sync.RWMutex + // Service represents a single monitored service. type Service struct { Name string `json:"name"` Description string `json:"description"` URL string `json:"url,omitempty"` - Status string `json:"status"` // "up", "degraded", "down", "unknown" + CheckURL string `json:"check_url,omitempty"` // HTTP URL probed automatically; empty = manual + Status string `json:"status"` // "up", "degraded", "down", "unknown" Note string `json:"note,omitempty"` } @@ -24,6 +28,12 @@ type Page struct { // Load reads and parses the status JSON from path. func Load(path string) (*Page, error) { + mu.RLock() + defer mu.RUnlock() + return load(path) +} + +func load(path string) (*Page, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err @@ -37,6 +47,8 @@ func Load(path string) (*Page, error) { // Save writes the status page data back to path. func Save(path string, p *Page) error { + mu.Lock() + defer mu.Unlock() p.LastChecked = time.Now().UTC() raw, err := json.MarshalIndent(p, "", " ") if err != nil { diff --git a/static/css/style.css b/static/css/style.css index c8c54a5..1e49491 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1328,6 +1328,157 @@ blockquote { .resume-skills dd { color: #333 !important; } } +/* === Honeypot field (hidden from humans) === */ + +.hp-field { + position: absolute; + left: -9999px; + top: -9999px; + width: 1px; + height: 1px; + overflow: hidden; + opacity: 0; + pointer-events: none; + tab-index: -1; +} + +/* === Subscribe widget === */ + +.subscribe-section { + margin-bottom: 2.5rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--border); +} + +.subscribe-section h2 { margin-bottom: 0.3em; } + +.subscribe-form { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.85rem; + max-width: 440px; +} + +.subscribe-form input[type="email"] { + flex: 1; + min-width: 200px; + padding: 0.5em 0.75em; + background: var(--bg); + border: 1px solid var(--border-dark); + border-radius: var(--radius); + color: var(--text); + font-size: 0.95rem; + font-family: var(--font-sans); +} + +.subscribe-form input[type="email"]:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; + border-color: var(--accent); +} + +/* === Uses Page === */ + +.uses-page { max-width: var(--max-w); } + +.uses-section { + margin-bottom: 2.5rem; +} + +.uses-section h2 { + font-size: 1.1rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0.35em; + margin-bottom: 1.25rem; +} + +.uses-item { + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.uses-item:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.uses-item-header { + display: flex; + align-items: baseline; + gap: 0.75rem; + margin-bottom: 0.4em; + flex-wrap: wrap; +} + +.uses-name { + font-family: var(--font-mono); + font-weight: 700; + font-size: 1rem; + color: var(--accent); +} + +.uses-role { + font-size: 0.85rem; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.uses-item p { margin: 0; font-size: 0.9rem; line-height: 1.7; } + +.uses-list { + font-size: 0.9rem; + line-height: 1.7; + margin: 0; +} + +.uses-list li { margin-bottom: 0.4em; } + +/* === Projects Page === */ + +.projects-page { max-width: var(--max-w); } + +.project-list { display: flex; flex-direction: column; gap: 0; } + +.project-item { + padding: 1.75rem 0; + border-bottom: 1px solid var(--border); +} + +.project-item:first-child { padding-top: 0; } +.project-item:last-child { border-bottom: none; } + +.project-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.6em; +} + +.project-title { + font-size: 1.15rem; + margin: 0; + line-height: 1.3; +} + +.project-item p { + font-size: 0.9rem; + line-height: 1.7; + color: var(--text); + margin-bottom: 0.75em; +} + +.project-links { + display: flex; + gap: 1rem; + font-size: 0.85rem; + font-family: var(--font-mono); +} + /* === Responsive === */ @media (max-width: 600px) { diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index cb14974..f35fded 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -8,6 +8,7 @@ New Post Edit Status Uploads + Newsletter View Site