commit 6c2140f2082b6adbb8987d70aaa2b3ee2f6e1902
parent a9e233b7c6e436996526aa03fdc665168a5f6c50
Author: Damian Myrda <damian@prime8.dev>
Date: Tue, 7 Apr 2026 21:53:47 -0500
feat: split server, web, and ssh/tui runtime architecture
Diffstat:
107 files changed, 5524 insertions(+), 2023 deletions(-)
diff --git a/.air.toml b/.air.toml
@@ -1,18 +0,0 @@
-root = "."
-tmp_dir = "build"
-
-[build]
- cmd = "mkdir -p ./build && go run ./cmd/buildjs && go build -o ./build/server ./cmd/server"
- bin = "./build/server"
- include_ext = ["go", "gohtml", "html", "css", "js", "yaml", "yml", "md"]
- exclude_dir = ["build", "vendor", "node_modules"]
- exclude_file = ["static/app.js"]
- delay = 200
- stop_on_error = true
- send_interrupt = true
-
-[log]
- time = true
-
-[screen]
- clear_on_rebuild = true
diff --git a/.air.web.toml b/.air.web.toml
@@ -0,0 +1,18 @@
+root = "."
+tmp_dir = "build"
+
+[build]
+cmd = "mkdir -p ./build && go run ./cmd/buildjs && go build -o ./build/server ./cmd/server"
+entrypoint = ["./build/server"]
+include_ext = ["go", "gohtml", "html", "css", "js", "yaml", "yml", "md"]
+exclude_dir = ["build", "vendor", "node_modules"]
+exclude_file = ["static/app.js"]
+delay = 200
+stop_on_error = true
+send_interrupt = true
+
+[log]
+time = true
+
+[screen]
+clear_on_rebuild = true
diff --git a/.gitignore b/.gitignore
@@ -9,3 +9,6 @@ static/app.js
# opencode
.opencode/
+
+# superset
+.superset/
diff --git a/Justfile b/Justfile
@@ -0,0 +1,13 @@
+set shell := ["bash", "-cu"]
+
+default:
+ just --list
+
+tui:
+ go run ./cmd/tui
+
+buildjs:
+ go run ./cmd/buildjs
+
+web:
+ air -c .air.web.toml
diff --git a/README.md b/README.md
@@ -13,20 +13,17 @@ Go + html/template backend for [prime8.dev](https://prime8.dev).
Run locally:
```sh
+go run ./cmd/tui
go run ./cmd/buildjs
go run ./cmd/server
```
-Run with hot reload (air):
+Or use `just` recipes:
```sh
-air
-```
-
-Run tests:
-
-```sh
-go test ./...
+just web
+just ssh
+just tui
```
Build Docker image:
diff --git a/cmd/buildjs/main.go b/cmd/buildjs/main.go
@@ -9,7 +9,7 @@ import (
func main() {
result := api.Build(api.BuildOptions{
- EntryPoints: []string{"internal/view/scripts/app.js"},
+ EntryPoints: []string{"internal/web/scripts/app.js"},
Outfile: "static/app.js",
Bundle: true,
MinifyWhitespace: true,
diff --git a/cmd/server/handlers/blog.go b/cmd/server/handlers/blog.go
@@ -0,0 +1,74 @@
+package handlers
+
+import (
+ "net/http"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/content"
+ "git.prime8.dev/moncheeta/prime8dev/internal/utils"
+ "git.prime8.dev/moncheeta/prime8dev/internal/web"
+ "github.com/go-chi/chi/v5"
+)
+
+func (h *Handler) BlogListHandler(w http.ResponseWriter, r *http.Request) {
+ about := config.GetAbout()
+ blogs, err := content.LoadBlogs()
+ if err != nil {
+ http.Error(w, "Failed to load blogs", http.StatusInternalServerError)
+ return
+ }
+
+ data := web.BaseData("Blog", "blog", about)
+ data.Blogs = blogs
+
+ if utils.IsHTMXRequest(r) {
+ h.renderTemplate(w, r, "blog", data, fragmentCacheMaxAgeSeconds)
+ return
+ }
+
+ h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
+}
+
+func (h *Handler) BlogPageHandler(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+
+ about := config.GetAbout()
+ blogs, err := content.LoadBlogs()
+ if err != nil {
+ http.Error(w, "Failed to load blogs", http.StatusInternalServerError)
+ return
+ }
+
+ blog, err := content.GetBlogByID(id)
+ if err != nil {
+ http.Error(w, "Failed to load blog", http.StatusInternalServerError)
+ return
+ }
+ if blog == nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ data := web.BaseData("Blog", "blog", about)
+ data.Blogs = blogs
+ data.SelectedBlogID = id
+ data.Blog = blog
+
+ h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
+}
+
+func (h *Handler) BlogDetailHandler(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ blog, err := content.GetBlogByID(id)
+ if err != nil {
+ http.Error(w, "Failed to load blog", http.StatusInternalServerError)
+ return
+ }
+ if blog == nil {
+ http.Error(w, "Blog not found", http.StatusNotFound)
+ return
+ }
+
+ data := web.Data{Blog: blog}
+ h.renderTemplate(w, r, "blog_detail", data, fragmentCacheMaxAgeSeconds)
+}
diff --git a/internal/handlers/cache.go b/cmd/server/handlers/cache.go
diff --git a/cmd/server/handlers/experience.go b/cmd/server/handlers/experience.go
@@ -0,0 +1,46 @@
+package handlers
+
+import (
+ "net/http"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/utils"
+ "git.prime8.dev/moncheeta/prime8dev/internal/web"
+ "github.com/go-chi/chi/v5"
+)
+
+func (h *Handler) ExperienceListHandler(w http.ResponseWriter, r *http.Request) {
+ about := config.GetAbout()
+ experience := config.GetExperience()
+ data := web.BaseData("Experience", "experience", about)
+ data.Experience = experience
+
+ if utils.IsHTMXRequest(r) {
+ h.renderTemplate(w, r, "experience", data, fragmentCacheMaxAgeSeconds)
+ return
+ }
+
+ h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
+}
+
+func (h *Handler) ExperiencePageHandler(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ exp := config.GetExperienceByID(id)
+ if exp == nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ about := config.GetAbout()
+ experience := config.GetExperience()
+ data := web.BaseData("Experience", "experience", about)
+ data.Experience = experience
+ data.SelectedExperienceID = id
+
+ if utils.IsHTMXRequest(r) {
+ h.renderTemplate(w, r, "experience", data, fragmentCacheMaxAgeSeconds)
+ return
+ }
+
+ h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
+}
diff --git a/cmd/server/handlers/handler.go b/cmd/server/handlers/handler.go
@@ -0,0 +1,30 @@
+package handlers
+
+import (
+ "time"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/external"
+ "git.prime8.dev/moncheeta/prime8dev/internal/web"
+ "github.com/patrickmn/go-cache"
+)
+
+type Handler struct {
+ renderer *web.Renderer
+ gitLabService *external.GitLab
+ wakaTimeService *external.WakaTime
+}
+
+func NewHandler() (*Handler, error) {
+ renderer, err := web.New()
+ if err != nil {
+ return nil, err
+ }
+
+ c := cache.New(5*time.Minute, 10*time.Minute)
+
+ return &Handler{
+ renderer: renderer,
+ gitLabService: external.NewGitLab(),
+ wakaTimeService: external.NewWakaTime(c),
+ }, nil
+}
diff --git a/cmd/server/handlers/home.go b/cmd/server/handlers/home.go
@@ -0,0 +1,24 @@
+package handlers
+
+import (
+ "net/http"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/utils"
+ "git.prime8.dev/moncheeta/prime8dev/internal/web"
+)
+
+func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) {
+ about := config.GetAbout()
+ experience := config.GetExperience()
+ data := web.BaseData("About", "about", about)
+ data.Experience = experience
+ data.CurrentJob = config.CurrentJob(experience)
+
+ if utils.IsHTMXRequest(r) {
+ h.renderTemplate(w, r, "about", data, fragmentCacheMaxAgeSeconds)
+ return
+ }
+
+ h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
+}
diff --git a/cmd/server/handlers/projects.go b/cmd/server/handlers/projects.go
@@ -0,0 +1,96 @@
+package handlers
+
+import (
+ "html/template"
+ "net/http"
+ "strings"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/content"
+ "git.prime8.dev/moncheeta/prime8dev/internal/utils"
+ "git.prime8.dev/moncheeta/prime8dev/internal/web"
+ "github.com/go-chi/chi/v5"
+)
+
+func (h *Handler) ProjectsPageHandler(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimSpace(chi.URLParam(r, "id"))
+ about := config.GetAbout()
+ projects, err := content.LoadProjects(h.gitLabService)
+ if err != nil {
+ http.Error(w, "Failed to load projects", http.StatusInternalServerError)
+ return
+ }
+
+ data := web.BaseData("Projects", "projects", about)
+ data.Projects = projects
+
+ if id == "" {
+ if len(projects) > 0 {
+ applyProjectDetailData(&data, &projects[0])
+ }
+
+ if utils.IsHTMXRequest(r) {
+ h.renderTemplate(w, r, "projects", data, fragmentCacheMaxAgeSeconds)
+ return
+ }
+
+ h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
+ return
+ }
+
+ project, err := content.GetProjectByID(h.gitLabService, id)
+ if err != nil {
+ http.Error(w, "Failed to load project", http.StatusInternalServerError)
+ return
+ }
+ if project == nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ data.SelectedProjectID = id
+ applyProjectDetailData(&data, project)
+
+ h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
+}
+
+func applyProjectDetailData(data *web.Data, project *config.Project) {
+ if data == nil || project == nil {
+ return
+ }
+
+ data.Project = project
+ data.ProjectLinkLabel = projectLinkLabel(project.URL)
+ data.ProjectReadme = template.HTML(project.HTML)
+}
+
+func (h *Handler) ProjectDetailHandler(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ project, err := content.GetProjectByID(h.gitLabService, id)
+ if err != nil {
+ http.Error(w, "Failed to load project", http.StatusInternalServerError)
+ return
+ }
+ if project == nil {
+ http.Error(w, "Project not found", http.StatusNotFound)
+ return
+ }
+
+ data := web.Data{
+ Project: project,
+ ProjectLinkLabel: projectLinkLabel(project.URL),
+ ProjectReadme: template.HTML(project.HTML),
+ }
+
+ h.renderTemplate(w, r, "project_detail", data, fragmentCacheMaxAgeSeconds)
+}
+
+func projectLinkLabel(raw string) string {
+ if strings.Contains(raw, "git.prime8.dev") || strings.Contains(raw, "gitlab.com") {
+ return "GitLab"
+ } else if strings.Contains(raw, "github.com") {
+ return "GitHub"
+ } else {
+ return "Outside"
+ }
+}
diff --git a/cmd/server/handlers/render.go b/cmd/server/handlers/render.go
@@ -0,0 +1,54 @@
+package handlers
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/web"
+)
+
+func (h *Handler) renderTemplate(w http.ResponseWriter, r *http.Request, name string, data web.Data, maxAgeSeconds int) {
+ if maxAgeSeconds < 0 {
+ maxAgeSeconds = 0
+ }
+
+ body, err := h.renderer.Render(name, data)
+ if err != nil {
+ http.Error(w, "Failed to render content", http.StatusInternalServerError)
+ return
+ }
+
+ sum := sha256.Sum256([]byte(body))
+ etag := fmt.Sprintf(`W/"%x"`, sum)
+ cacheEnabled := shouldCacheResponses()
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if !cacheEnabled {
+ applyNoCacheHeaders(w)
+ } else if maxAgeSeconds > 0 {
+ w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAgeSeconds))
+ w.Header().Set("ETag", etag)
+ }
+ w.Header().Set("Vary", "HX-Request")
+
+ if cacheEnabled && maxAgeSeconds > 0 && strings.Contains(r.Header.Get("If-None-Match"), etag) {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ _, _ = w.Write([]byte(body))
+}
+
+func shouldCacheResponses() bool {
+ mode := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV")))
+ return mode == "prod"
+}
+
+func applyNoCacheHeaders(w http.ResponseWriter) {
+ w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
+ w.Header().Set("Pragma", "no-cache")
+ w.Header().Set("Expires", "0")
+}
diff --git a/cmd/server/handlers/sitemap.go b/cmd/server/handlers/sitemap.go
@@ -0,0 +1,42 @@
+package handlers
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/content"
+)
+
+func (h *Handler) SitemapHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+
+ var xml bytes.Buffer
+ xml.WriteString(`<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
+
+ blogs, _ := content.LoadBlogs()
+ projects, _ := content.LoadProjects(h.gitLabService)
+ experience := config.GetExperience()
+
+ routes := []string{"/", "/projects", "/experience", "/blog"}
+ for _, route := range routes {
+ fmt.Fprintf(&xml, "<url><loc>%s%s</loc></url>", config.SiteURL, route)
+ }
+
+ for _, p := range projects {
+ fmt.Fprintf(&xml, "<url><loc>%s/projects/%s</loc></url>", config.SiteURL, p.ID)
+ }
+
+ for _, exp := range experience {
+ fmt.Fprintf(&xml, "<url><loc>%s/experience/%s</loc></url>", config.SiteURL, exp.ID)
+ }
+
+ for _, b := range blogs {
+ fmt.Fprintf(&xml, "<url><loc>%s/blog/%s</loc></url>", config.SiteURL, b.ID)
+ }
+
+ xml.WriteString("</urlset>")
+ _, _ = w.Write(xml.Bytes())
+}
diff --git a/internal/handlers/wakatime.go b/cmd/server/handlers/wakatime.go
diff --git a/cmd/server/logging.go b/cmd/server/logging.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+ "log/slog"
+ "os"
+ "strings"
+)
+
+func configureLogger() {
+ slog.SetDefault(slog.New(slog.NewTextHandler(
+ os.Stdout,
+ &slog.HandlerOptions{Level: envLogLevel("LOG_LEVEL", slog.LevelInfo)},
+ )))
+}
+
+func envLogLevel(key string, fallback slog.Level) slog.Level {
+ raw := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
+ if raw == "" {
+ return fallback
+ }
+
+ var lvl slog.Level
+ if err := lvl.UnmarshalText([]byte(raw)); err != nil {
+ return fallback
+ }
+
+ return lvl
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
@@ -1,14 +1,15 @@
package main
import (
- "fmt"
- "log"
+ "context"
+ "errors"
+ "log/slog"
"net/http"
"os"
"time"
- "git.prime8.dev/moncheeta/prime8dev/internal/content"
- "git.prime8.dev/moncheeta/prime8dev/internal/handlers"
+ "git.prime8.dev/moncheeta/prime8dev/cmd/server/handlers"
+ sshserver "git.prime8.dev/moncheeta/prime8dev/internal/ssh"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv"
@@ -16,18 +17,24 @@ import (
func main() {
_ = godotenv.Load()
- if err := content.InitBlogs(); err != nil {
- log.Fatalf("Failed to load blogs: %v", err)
+ configureLogger()
+
+ if err := run(); err != nil {
+ slog.Error("server stopped", "error", err)
}
+}
- r := chi.NewRouter()
+func run() error {
+ h, err := handlers.NewHandler()
+ if err != nil {
+ return err
+ }
+ r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
- h := handlers.NewHandler()
-
fs := http.FileServer(http.Dir("./static"))
r.Handle("/static/*", http.StripPrefix("/static/", fs))
serveStaticFile := func(file string) http.HandlerFunc {
@@ -40,8 +47,8 @@ func main() {
r.Get("/site.webmanifest", serveStaticFile("site.webmanifest"))
r.Get("/", h.HomeHandler)
- r.Get("/projects", h.ProjectsListHandler)
- r.Get("/projects/{id}", h.ProjectPageHandler)
+ r.Get("/projects", h.ProjectsPageHandler)
+ r.Get("/projects/{id}", h.ProjectsPageHandler)
r.Get("/experience", h.ExperienceListHandler)
r.Get("/experience/{id}", h.ExperiencePageHandler)
r.Get("/blog", h.BlogListHandler)
@@ -57,8 +64,39 @@ func main() {
port = "3000"
}
- log.Printf("Server starting on :%s", port)
- if err := http.ListenAndServe(fmt.Sprintf(":%s", port), r); err != nil {
- log.Fatal(err)
+ sshCtx, cancelSSH := context.WithCancel(context.Background())
+ defer cancelSSH()
+ sshErrCh := make(chan error, 1)
+ go func() {
+ if err := sshserver.Run(sshCtx, slog.Default()); err != nil {
+ sshErrCh <- err
+ }
+ }()
+
+ httpServer := &http.Server{
+ Addr: ":" + port,
+ Handler: r,
+ }
+ defer func() {
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := httpServer.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ slog.Error("http server shutdown error", "error", err)
+ }
+ }()
+
+ httpErrCh := make(chan error, 1)
+ go func() {
+ slog.Info("http server starting", "addr", httpServer.Addr)
+ if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ httpErrCh <- err
+ }
+ }()
+
+ select {
+ case err := <-sshErrCh:
+ return err
+ case err := <-httpErrCh:
+ return err
}
}
diff --git a/cmd/tui/main.go b/cmd/tui/main.go
@@ -0,0 +1,18 @@
+package main
+
+import (
+ "log/slog"
+ "os"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/tui"
+ "github.com/joho/godotenv"
+)
+
+func main() {
+ _ = godotenv.Load()
+
+ if err := tui.RunLocal(os.Getenv("USER")); err != nil {
+ slog.Error("tui exited with error", "error", err)
+ os.Exit(1)
+ }
+}
diff --git a/env.example b/env.example
@@ -1,3 +1,4 @@
APP_ENV=dev
PORT=3000
+GITLAB_HOST=
WAKATIME_API_KEY=your_wakatime_api_key_here
diff --git a/generate_cloud_config.sh b/generate_cloud_config.sh
@@ -72,7 +72,7 @@ ssh_pwauth: false
write_files:
- path: /etc/ssh/sshd_config.d/ssh-hardening.conf
content: |
- Port 22
+ Port 2000
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
diff --git a/go.mod b/go.mod
@@ -3,16 +3,73 @@ module git.prime8.dev/moncheeta/prime8dev
go 1.23.0
require (
+ github.com/MichaelMure/go-term-markdown v0.1.4
github.com/go-chi/chi/v5 v5.2.5
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/yuin/goldmark v1.8.2
)
require (
+ github.com/TheZoraiz/ascii-image-converter v1.13.1
+ github.com/charmbracelet/bubbles v0.21.0
+ github.com/charmbracelet/bubbletea v1.3.4
+ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
+ github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894
+ github.com/charmbracelet/wish v1.4.7
github.com/evanw/esbuild v0.27.4
github.com/joho/godotenv v1.5.1
github.com/romance-dev/ascii-art v1.0.1
+ golang.org/x/crypto v0.36.0
gopkg.in/yaml.v3 v3.0.1
)
-require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
+require (
+ github.com/MichaelMure/go-term-text v0.3.1 // indirect
+ github.com/alecthomas/chroma v0.7.1 // indirect
+ github.com/alecthomas/repr v0.4.0 // indirect
+ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+ github.com/charmbracelet/keygen v0.5.3 // indirect
+ github.com/charmbracelet/log v0.4.1 // indirect
+ github.com/charmbracelet/x/ansi v0.10.1 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
+ github.com/charmbracelet/x/conpty v0.1.0 // indirect
+ github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
+ github.com/charmbracelet/x/input v0.3.4 // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/charmbracelet/x/termios v0.1.0 // indirect
+ github.com/charmbracelet/x/windows v0.2.0 // indirect
+ github.com/creack/pty v1.1.21 // indirect
+ github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
+ github.com/disintegration/imaging v1.6.2 // indirect
+ github.com/dlclark/regexp2 v1.11.0 // indirect
+ github.com/eliukblau/pixterm/pkg/ansimage v0.0.0-20191210081756-9fb6cf8c2f75 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/fatih/color v1.9.0 // indirect
+ github.com/fogleman/gg v1.3.0 // indirect
+ github.com/go-logfmt/logfmt v0.6.0 // indirect
+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
+ github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098 // indirect
+ github.com/gookit/color v1.4.2 // indirect
+ github.com/kyokomi/emoji/v2 v2.2.8 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/makeworld-the-better-one/dither/v2 v2.2.0 // indirect
+ github.com/mattn/go-colorable v0.1.4 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/nathan-fiscaletti/consolesize-go v0.0.0-20210105204122-a87d9f614b9d // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+ golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
+ golang.org/x/net v0.36.0 // indirect
+ golang.org/x/sync v0.13.0 // indirect
+ golang.org/x/sys v0.32.0 // indirect
+ golang.org/x/term v0.31.0 // indirect
+ golang.org/x/text v0.24.0 // indirect
+)
diff --git a/go.sum b/go.sum
@@ -1,18 +1,182 @@
+github.com/MichaelMure/go-term-markdown v0.1.4 h1:Ir3kBXDUtOX7dEv0EaQV8CNPpH+T7AfTh0eniMOtNcs=
+github.com/MichaelMure/go-term-markdown v0.1.4/go.mod h1:EhcA3+pKYnlUsxYKBJ5Sn1cTQmmBMjeNlpV8nRb+JxA=
+github.com/MichaelMure/go-term-text v0.3.1 h1:Kw9kZanyZWiCHOYu9v/8pWEgDQ6UVN9/ix2Vd2zzWf0=
+github.com/MichaelMure/go-term-text v0.3.1/go.mod h1:QgVjAEDUnRMlzpS6ky5CGblux7ebeiLnuy9dAaFZu8o=
+github.com/TheZoraiz/ascii-image-converter v1.13.1 h1:lGgOd8obT7hgTF6JDkz1v213/pBHZMtQxxJcEHWjp6I=
+github.com/TheZoraiz/ascii-image-converter v1.13.1/go.mod h1:OdQ0YlyFkUN/h9Hu2OU4cSoAMZf/5J5pOEGeU0TPVsA=
+github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
+github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
+github.com/alecthomas/chroma v0.7.1 h1:G1i02OhUbRi2nJxcNkwJaY/J1gHXj9tt72qN6ZouLFQ=
+github.com/alecthomas/chroma v0.7.1/go.mod h1:gHw09mkX1Qp80JlYbmN9L3+4R5o6DJJ3GRShh+AICNc=
+github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
+github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
+github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
+github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
+github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
+github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
+github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
+github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
+github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
+github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
+github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
+github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
+github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
+github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 h1:Ffon9TbltLGBsT6XE//YvNuu4OAaThXioqalhH11xEw=
+github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894/go.mod h1:hg+I6gvlMl16nS9ZzQNgBIrrCasGwEw0QiLsDcP01Ko=
+github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
+github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
+github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
+github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
+github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
+github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k=
+github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U=
+github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
+github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
+github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
+github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
+github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/eliukblau/pixterm/pkg/ansimage v0.0.0-20191210081756-9fb6cf8c2f75 h1:vbix8DDQ/rfatfFr/8cf/sJfIL69i4BcZfjrVOxsMqk=
+github.com/eliukblau/pixterm/pkg/ansimage v0.0.0-20191210081756-9fb6cf8c2f75/go.mod h1:0gZuvTO1ikSA5LtTI6E13LEOdWQNjIo5MTQOvrV0eFg=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/evanw/esbuild v0.27.4 h1:8opEixKkH9EDsdjxC/aPmpk1KPwQOcyknDo5m5xIFxI=
github.com/evanw/esbuild v0.27.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
+github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
+github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
+github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
+github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
+github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
+github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098 h1:Qxs3bNRWe8GTcKMxYOSXm0jx6j0de8XUtb/fsP3GZ0I=
+github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU=
+github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk=
+github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/kyokomi/emoji/v2 v2.2.8 h1:jcofPxjHWEkJtkIbcLHvZhxKgCPl6C7MyjTrD4KDqUE=
+github.com/kyokomi/emoji/v2 v2.2.8/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
+github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/makeworld-the-better-one/dither/v2 v2.2.0 h1:VTMAiyyO1YIO07fZwuLNZZasJgKUmvsIA48ze3ALHPQ=
+github.com/makeworld-the-better-one/dither/v2 v2.2.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
+github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/nathan-fiscaletti/consolesize-go v0.0.0-20210105204122-a87d9f614b9d h1:PQW4Aqovdqc9efHl9EVA+bhKmuZ4ME1HvSYYDvaDiK0=
+github.com/nathan-fiscaletti/consolesize-go v0.0.0-20210105204122-a87d9f614b9d/go.mod h1:cxIIfNMTwff8f/ZvRouvWYF6wOoO7nj99neWSx2q/Es=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/romance-dev/ascii-art v1.0.1 h1:mBnFjBpZVNBkgKT/SRS/a3ToeabLq4yU3bFHgt4U/cI=
github.com/romance-dev/ascii-art v1.0.1/go.mod h1:Bq49vKfrqe73mR/tyu2PSr4qPaS+tsX0cdk/7ZuZUqw=
+github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
-golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
+golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20191206065243-da761ea9ff43/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs=
+golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
+golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
+golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
+golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
+golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/config/projects.go b/internal/config/projects.go
@@ -7,6 +7,8 @@ type Project struct {
Tech []string
URL string
APIURL string
+ Markdown string
+ HTML string
}
func GetProjects() []Project {
diff --git a/internal/content/blogs.go b/internal/content/blogs.go
@@ -2,16 +2,15 @@ package content
import (
"errors"
+ "log/slog"
"os"
"path/filepath"
"sort"
"strings"
- "sync"
"time"
- "gopkg.in/yaml.v3"
-
"git.prime8.dev/moncheeta/prime8dev/internal/utils"
+ "gopkg.in/yaml.v3"
)
type Blog struct {
@@ -19,7 +18,8 @@ type Blog struct {
Title string
Description string
Date time.Time
- Content string
+ Markdown string
+ HTML string
URL string
Image string
Tags []string
@@ -36,77 +36,69 @@ type frontmatter struct {
}
var (
- blogsOnce sync.Once
- blogsCache []Blog
- blogsByID map[string]Blog
- blogsErr error
+ blogsList = loadBlogsOnStartup()
+ blogsByID = mapBlogsByID(blogsList)
)
-func InitBlogs() error {
- _, err := loadBlogsCache()
- return err
-}
+func GetBlogByID(id string) (*Blog, error) {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ return nil, nil
+ }
-func LoadBlogs() ([]Blog, error) {
- blogs, err := loadBlogsCache()
- if err != nil {
- return nil, err
+ b, ok := blogsByID[id]
+ if !ok {
+ return nil, nil
}
- copyBlogs := make([]Blog, len(blogs))
- copy(copyBlogs, blogs)
- return copyBlogs, nil
+ copy := b
+ return ©, nil
}
-func loadBlogsCache() ([]Blog, error) {
- blogsOnce.Do(func() {
- paths, err := filepath.Glob("./content/blogs/*.md")
- if err != nil {
- blogsErr = err
- return
- }
-
- blogs := make([]Blog, 0, len(paths))
- for _, p := range paths {
- b, err := parseBlogFile(p)
- if err != nil {
- blogsErr = err
- return
- }
- blogs = append(blogs, b)
- }
-
- sort.Slice(blogs, func(i, j int) bool {
- return blogs[i].Date.After(blogs[j].Date)
- })
-
- blogsCache = blogs
- blogsByID = make(map[string]Blog, len(blogs))
- for _, b := range blogs {
- blogsByID[b.ID] = b
- }
- })
+func LoadBlogs() ([]Blog, error) {
+ copyBlogs := make([]Blog, len(blogsList))
+ copy(copyBlogs, blogsList)
+ return copyBlogs, nil
+}
- if blogsErr != nil {
- return nil, blogsErr
+func loadBlogsOnStartup() []Blog {
+ blogs, err := loadBlogsFromDisk()
+ if err != nil {
+ slog.Warn("failed to load blogs on startup", "error", err)
+ return []Blog{}
}
- return blogsCache, nil
+ return blogs
}
-func GetBlogByID(id string) (*Blog, error) {
- _, err := loadBlogsCache()
+func loadBlogsFromDisk() ([]Blog, error) {
+ paths, err := filepath.Glob("./content/blogs/*.md")
if err != nil {
return nil, err
}
- b, ok := blogsByID[id]
- if !ok {
- return nil, nil
+ blogs := make([]Blog, 0, len(paths))
+ for _, p := range paths {
+ b, err := parseBlogFile(p)
+ if err != nil {
+ return nil, err
+ }
+ blogs = append(blogs, b)
}
- copy := b
- return ©, nil
+ sort.Slice(blogs, func(i, j int) bool {
+ return blogs[i].Date.After(blogs[j].Date)
+ })
+
+ return blogs, nil
+}
+
+func mapBlogsByID(blogs []Blog) map[string]Blog {
+ byID := make(map[string]Blog, len(blogs))
+ for _, b := range blogs {
+ byID[b.ID] = b
+ }
+ return byID
}
func parseBlogFile(path string) (Blog, error) {
@@ -130,9 +122,15 @@ func parseBlogFile(path string) (Blog, error) {
return Blog{}, err
}
- html, err := utils.MarkdownToHTML(parts[2])
- if err != nil {
- return Blog{}, err
+ markdown := strings.TrimSpace(parts[2])
+ html := ""
+ if fm.URL == "" && markdown != "" {
+ rendered, err := utils.MarkdownToHTML(markdown)
+ if err != nil {
+ slog.Warn("failed to render blog markdown", "blog_id", fm.ID, "path", path, "error", err)
+ return Blog{}, err
+ }
+ html = rendered
}
return Blog{
@@ -140,7 +138,8 @@ func parseBlogFile(path string) (Blog, error) {
Title: fm.Title,
Description: fm.Description,
Date: date,
- Content: html,
+ Markdown: markdown,
+ HTML: html,
URL: fm.URL,
Image: fm.Image,
Tags: fm.Tags,
diff --git a/internal/content/projects.go b/internal/content/projects.go
@@ -0,0 +1,165 @@
+package content
+
+import (
+ "context"
+ "log/slog"
+ "strings"
+ "sync"
+ "time"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/external"
+ "git.prime8.dev/moncheeta/prime8dev/internal/utils"
+ "github.com/patrickmn/go-cache"
+)
+
+const (
+ projectsCacheKey = "projects"
+ projectsCacheTTL = 1 * time.Hour
+ projectsCacheCleanup = 4 * time.Hour
+)
+
+var (
+ projectsCache = cache.New(projectsCacheTTL, projectsCacheCleanup)
+ projectsRefreshMu sync.Mutex
+ projectsRefreshInProgress bool
+)
+
+func LoadProjects(gitLabService *external.GitLab) ([]config.Project, error) {
+ if gitLabService == nil {
+ gitLabService = external.NewGitLab()
+ }
+
+ if cached, found := projectsCache.Get(projectsCacheKey); found {
+ if projects, ok := cached.([]config.Project); ok {
+ return cloneProjects(projects), nil
+ }
+ }
+
+ projects := config.GetProjects()
+ projectsCache.Set(projectsCacheKey, projects, projectsCacheTTL)
+ startProjectsRefresh(gitLabService)
+
+ return cloneProjects(projects), nil
+}
+
+func GetProjectByID(gitLabService *external.GitLab, id string) (*config.Project, error) {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ return nil, nil
+ }
+
+ projects, err := LoadProjects(gitLabService)
+ if err != nil {
+ return nil, err
+ }
+
+ for i := range projects {
+ if projects[i].ID != id {
+ continue
+ }
+ if gitLabService != nil && strings.TrimSpace(projects[i].Markdown) == "" && strings.TrimSpace(projects[i].HTML) == "" {
+ if readme, err := fetchProjectReadme(gitLabService, projects[i]); err == nil {
+ projects[i].Markdown = readme.Markdown
+ projects[i].HTML = readme.HTML
+ projectsCache.Set(projectsCacheKey, cloneProjects(projects), projectsCacheTTL)
+ }
+ }
+
+ project := projects[i]
+ return &project, nil
+ }
+
+ return nil, nil
+}
+
+func fetchProjects(gitLabService *external.GitLab) ([]config.Project, error) {
+ projects := config.GetProjects()
+ for i := range projects {
+ readme, err := fetchProjectReadme(gitLabService, projects[i])
+ if err != nil {
+ slog.Warn("project metadata fetch failed", "project_id", projects[i].ID, "error", err)
+ continue
+ }
+
+ projects[i].Markdown = readme.Markdown
+ projects[i].HTML = readme.HTML
+ }
+
+ return projects, nil
+}
+
+func fetchProjectReadme(gitLabService *external.GitLab, project config.Project) (config.Project, error) {
+ metadata, err := gitLabService.FetchMetadata(context.Background(), project)
+ if err != nil {
+ return config.Project{}, err
+ }
+ if metadata == nil {
+ return config.Project{}, nil
+ }
+
+ html, err := projectReadmeHTML(metadata)
+ if err != nil {
+ slog.Warn("project readme markdown render failed", "project_id", project.ID, "error", err)
+ return config.Project{}, err
+ }
+
+ return config.Project{
+ Markdown: strings.TrimSpace(metadata.Readme),
+ HTML: strings.TrimSpace(html),
+ }, nil
+}
+
+func projectReadmeHTML(metadata *external.GitMetadata) (string, error) {
+ if metadata == nil {
+ return "", nil
+ }
+
+ markdown := strings.TrimSpace(metadata.Readme)
+ if markdown == "" {
+ return "", nil
+ }
+
+ rendered, err := utils.MarkdownToHTML(utils.RemoveFirstH1(markdown))
+ if err != nil {
+ return "", err
+ }
+
+ return rendered, nil
+}
+
+func cloneProjects(projects []config.Project) []config.Project {
+ cloned := make([]config.Project, len(projects))
+ copy(cloned, projects)
+ return cloned
+}
+
+func startProjectsRefresh(gitLabService *external.GitLab) {
+ if gitLabService == nil {
+ return
+ }
+
+ projectsRefreshMu.Lock()
+ if projectsRefreshInProgress {
+ projectsRefreshMu.Unlock()
+ return
+ }
+ projectsRefreshInProgress = true
+ projectsRefreshMu.Unlock()
+
+ go func() {
+ defer func() {
+ projectsRefreshMu.Lock()
+ projectsRefreshInProgress = false
+ projectsRefreshMu.Unlock()
+ }()
+
+ projects, err := fetchProjects(gitLabService)
+ if err != nil {
+ slog.Warn("project metadata background refresh failed", "error", err)
+ return
+ }
+
+ projectsCache.Set(projectsCacheKey, projects, projectsCacheTTL)
+ }()
+}
diff --git a/internal/external/gitlab.go b/internal/external/gitlab.go
@@ -1,6 +1,7 @@
package external
import (
+ "context"
"encoding/json"
"fmt"
"io"
@@ -13,24 +14,19 @@ import (
"git.prime8.dev/moncheeta/prime8dev/internal/config"
"git.prime8.dev/moncheeta/prime8dev/internal/utils"
- "github.com/patrickmn/go-cache"
)
type GitLab struct {
- cache *cache.Cache
httpClient *http.Client
apiBaseURL string
}
-const gitLabMetadataTTL = 15 * time.Minute
-
-func NewGitLab(cache *cache.Cache) *GitLab {
+func NewGitLab() *GitLab {
return &GitLab{
- cache: cache,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
- apiBaseURL: strings.TrimSpace(os.Getenv("GITLAB_API_BASE_URL")),
+ apiBaseURL: strings.TrimSpace(os.Getenv("GITLAB_HOST")),
}
}
@@ -40,36 +36,27 @@ type GitMetadata struct {
DateModified time.Time
}
-func (s *GitLab) FetchMetadata(project config.Project) (*GitMetadata, error) {
- cacheKey := "gitlab:" + project.ID
-
- if val, found := s.cache.Get(cacheKey); found {
- return val.(*GitMetadata), nil
- }
-
- metadata, err := s.fetchFromGitLab(project)
- if err != nil {
- return nil, err
+func (s *GitLab) FetchMetadata(ctx context.Context, project config.Project) (*GitMetadata, error) {
+ if ctx == nil {
+ ctx = context.Background()
}
- s.cache.Set(cacheKey, metadata, gitLabMetadataTTL)
- return metadata, nil
+ return s.fetchFromGitLab(ctx, project)
}
-func (s *GitLab) RefreshMetadata(project config.Project) error {
- metadata, err := s.fetchFromGitLab(project)
- if err != nil {
- return err
+func (s *GitLab) RefreshMetadata(ctx context.Context, project config.Project) error {
+ if ctx == nil {
+ ctx = context.Background()
}
- s.cache.Set("gitlab:"+project.ID, metadata, gitLabMetadataTTL)
- return nil
+ _, err := s.fetchFromGitLab(ctx, project)
+ return err
}
-func (s *GitLab) fetchFromGitLab(project config.Project) (*GitMetadata, error) {
+func (s *GitLab) fetchFromGitLab(ctx context.Context, project config.Project) (*GitMetadata, error) {
apiURL := s.rewriteGitLabURL(project.APIURL)
- req, err := http.NewRequest("GET", apiURL, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
@@ -103,9 +90,8 @@ func (s *GitLab) fetchFromGitLab(project config.Project) (*GitMetadata, error) {
readme := ""
if repo.ReadmeURL != "" {
- readmeURL := strings.Replace(utils.EnsureHTTPS(repo.ReadmeURL), "/-/blob/", "/-/raw/", 1)
- readmeURL = s.rewriteGitLabURL(readmeURL)
- readme = s.fetchReadme(webURL, repo.DefaultBranch, readmeURL)
+ readmeURL := utils.EnsureHTTPS(s.rewriteGitLabURL(strings.Replace(repo.ReadmeURL, "/-/blob/", "/-/raw/", 1)))
+ readme = s.fetchReadme(ctx, webURL, repo.DefaultBranch, readmeURL)
}
return &GitMetadata{
@@ -147,8 +133,13 @@ func (s *GitLab) rewriteGitLabURL(rawURL string) string {
return fmt.Sprintf("%s://%s%s", base.Scheme, base.Host, suffix)
}
-func (s *GitLab) fetchReadme(webURL, branch, readmeURL string) string {
- resp, err := s.httpClient.Get(readmeURL)
+func (s *GitLab) fetchReadme(ctx context.Context, webURL, branch, readmeURL string) string {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, readmeURL, nil)
+ if err != nil {
+ return ""
+ }
+
+ resp, err := s.httpClient.Do(req)
if err != nil {
return ""
}
diff --git a/internal/external/wakatime.go b/internal/external/wakatime.go
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"os"
+ "strings"
"time"
"github.com/patrickmn/go-cache"
@@ -14,21 +15,19 @@ import (
type WakaTime struct {
httpClient *http.Client
- apiKey string
baseURL string
+ apiKey string
cache *cache.Cache
}
-func NewWakaTime(cache *cache.Cache) *WakaTime {
- apiKey := os.Getenv("WAKATIME_API_KEY")
-
+func NewWakaTime(c *cache.Cache) *WakaTime {
return &WakaTime{
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
- apiKey: apiKey,
baseURL: "https://wakatime.com/api/v1",
- cache: cache,
+ apiKey: strings.TrimSpace(os.Getenv("WAKATIME_API_KEY")),
+ cache: c,
}
}
diff --git a/internal/handlers/blog.go b/internal/handlers/blog.go
@@ -1,74 +0,0 @@
-package handlers
-
-import (
- "net/http"
-
- "git.prime8.dev/moncheeta/prime8dev/internal/config"
- "git.prime8.dev/moncheeta/prime8dev/internal/content"
- "git.prime8.dev/moncheeta/prime8dev/internal/utils"
- "git.prime8.dev/moncheeta/prime8dev/internal/view"
- "github.com/go-chi/chi/v5"
-)
-
-func (h *Handler) BlogListHandler(w http.ResponseWriter, r *http.Request) {
- about := config.GetAbout()
- blogs, err := content.LoadBlogs()
- if err != nil {
- http.Error(w, "Failed to load blogs", http.StatusInternalServerError)
- return
- }
-
- data := view.BaseData("Blog", "blog", about)
- data.Blogs = blogs
-
- if utils.IsHTMXRequest(r) {
- h.renderTemplate(w, r, "blog", data, fragmentCacheMaxAgeSeconds)
- return
- }
-
- h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
-}
-
-func (h *Handler) BlogPageHandler(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
-
- about := config.GetAbout()
- blogs, err := content.LoadBlogs()
- if err != nil {
- http.Error(w, "Failed to load blogs", http.StatusInternalServerError)
- return
- }
-
- blog, err := content.GetBlogByID(id)
- if err != nil {
- http.Error(w, "Failed to load blog", http.StatusInternalServerError)
- return
- }
- if blog == nil {
- http.NotFound(w, r)
- return
- }
-
- data := view.BaseData("Blog", "blog", about)
- data.Blogs = blogs
- data.SelectedBlogID = id
- data.Blog = blog
-
- h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
-}
-
-func (h *Handler) BlogDetailHandler(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- blog, err := content.GetBlogByID(id)
- if err != nil {
- http.Error(w, "Failed to load blog", http.StatusInternalServerError)
- return
- }
- if blog == nil {
- http.Error(w, "Blog not found", http.StatusNotFound)
- return
- }
-
- data := view.Data{Blog: blog}
- h.renderTemplate(w, r, "blog_detail", data, fragmentCacheMaxAgeSeconds)
-}
diff --git a/internal/handlers/experience.go b/internal/handlers/experience.go
@@ -1,46 +0,0 @@
-package handlers
-
-import (
- "net/http"
-
- "git.prime8.dev/moncheeta/prime8dev/internal/config"
- "git.prime8.dev/moncheeta/prime8dev/internal/utils"
- "git.prime8.dev/moncheeta/prime8dev/internal/view"
- "github.com/go-chi/chi/v5"
-)
-
-func (h *Handler) ExperienceListHandler(w http.ResponseWriter, r *http.Request) {
- about := config.GetAbout()
- experience := config.GetExperience()
- data := view.BaseData("Experience", "experience", about)
- data.Experience = experience
-
- if utils.IsHTMXRequest(r) {
- h.renderTemplate(w, r, "experience", data, fragmentCacheMaxAgeSeconds)
- return
- }
-
- h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
-}
-
-func (h *Handler) ExperiencePageHandler(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- exp := config.GetExperienceByID(id)
- if exp == nil {
- http.NotFound(w, r)
- return
- }
-
- about := config.GetAbout()
- experience := config.GetExperience()
- data := view.BaseData("Experience", "experience", about)
- data.Experience = experience
- data.SelectedExperienceID = id
-
- if utils.IsHTMXRequest(r) {
- h.renderTemplate(w, r, "experience", data, fragmentCacheMaxAgeSeconds)
- return
- }
-
- h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
-}
diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go
@@ -1,57 +0,0 @@
-package handlers
-
-import (
- "log"
- "time"
-
- "git.prime8.dev/moncheeta/prime8dev/internal/config"
- "git.prime8.dev/moncheeta/prime8dev/internal/external"
- "git.prime8.dev/moncheeta/prime8dev/internal/view"
- "github.com/patrickmn/go-cache"
-)
-
-type Handler struct {
- cache *cache.Cache
- gitLabService *external.GitLab
- wakaTimeService *external.WakaTime
- renderer *view.Renderer
-}
-
-func NewHandler() *Handler {
- c := cache.New(5*time.Minute, 10*time.Minute)
- renderer, err := view.New()
- if err != nil {
- log.Fatalf("failed to initialize templates: %v", err)
- }
-
- h := &Handler{
- cache: c,
- gitLabService: external.NewGitLab(c),
- wakaTimeService: external.NewWakaTime(c),
- renderer: renderer,
- }
-
- h.startProjectMetadataRefresh()
- return h
-}
-
-func (h *Handler) startProjectMetadataRefresh() {
- go func() {
- h.refreshProjectMetadata()
-
- ticker := time.NewTicker(5 * time.Minute)
- defer ticker.Stop()
-
- for range ticker.C {
- h.refreshProjectMetadata()
- }
- }()
-}
-
-func (h *Handler) refreshProjectMetadata() {
- for _, project := range config.GetProjects() {
- if err := h.gitLabService.RefreshMetadata(project); err != nil {
- log.Printf("project metadata refresh failed for %s: %v", project.ID, err)
- }
- }
-}
diff --git a/internal/handlers/home.go b/internal/handlers/home.go
@@ -1,24 +0,0 @@
-package handlers
-
-import (
- "net/http"
-
- "git.prime8.dev/moncheeta/prime8dev/internal/config"
- "git.prime8.dev/moncheeta/prime8dev/internal/utils"
- "git.prime8.dev/moncheeta/prime8dev/internal/view"
-)
-
-func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) {
- about := config.GetAbout()
- experience := config.GetExperience()
- data := view.BaseData("About", "about", about)
- data.Experience = experience
- data.CurrentJob = config.CurrentJob(experience)
-
- if utils.IsHTMXRequest(r) {
- h.renderTemplate(w, r, "about", data, fragmentCacheMaxAgeSeconds)
- return
- }
-
- h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
-}
diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go
@@ -1,91 +0,0 @@
-package handlers
-
-import (
- "html/template"
- "net/http"
- "strings"
-
- "git.prime8.dev/moncheeta/prime8dev/internal/config"
- "git.prime8.dev/moncheeta/prime8dev/internal/utils"
- "git.prime8.dev/moncheeta/prime8dev/internal/view"
- "github.com/go-chi/chi/v5"
-)
-
-func (h *Handler) ProjectsListHandler(w http.ResponseWriter, r *http.Request) {
- about := config.GetAbout()
- projects := config.GetProjects()
- data := view.BaseData("Projects", "projects", about)
- data.Projects = projects
-
- if utils.IsHTMXRequest(r) {
- h.renderTemplate(w, r, "projects", data, fragmentCacheMaxAgeSeconds)
- return
- }
-
- h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
-}
-
-func (h *Handler) ProjectPageHandler(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- project := config.GetProjectByID(id)
- if project == nil {
- http.NotFound(w, r)
- return
- }
-
- about := config.GetAbout()
- projects := config.GetProjects()
- data := view.BaseData("Projects", "projects", about)
- data.Projects = projects
- data.SelectedProjectID = id
- data.Project = project
- data.ProjectLinkLabel = projectLinkLabel(project.URL)
-
- metadata, err := h.gitLabService.FetchMetadata(*project)
- if err == nil && metadata != nil && metadata.Readme != "" {
- html, mdErr := utils.MarkdownToHTML(metadata.Readme)
- if mdErr == nil {
- data.ProjectReadme = template.HTML(html)
- }
- }
-
- h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
-}
-
-func (h *Handler) ProjectDetailHandler(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- project := config.GetProjectByID(id)
- if project == nil {
- http.Error(w, "Project not found", http.StatusNotFound)
- return
- }
-
- metadata, err := h.gitLabService.FetchMetadata(*project)
- if err != nil {
- metadata = nil
- }
-
- data := view.Data{
- Project: project,
- ProjectLinkLabel: projectLinkLabel(project.URL),
- }
-
- if metadata != nil && metadata.Readme != "" {
- html, err := utils.MarkdownToHTML(metadata.Readme)
- if err == nil {
- data.ProjectReadme = template.HTML(html)
- }
- }
-
- h.renderTemplate(w, r, "project_detail", data, fragmentCacheMaxAgeSeconds)
-}
-
-func projectLinkLabel(raw string) string {
- if strings.Contains(raw, "git.prime8.dev") || strings.Contains(raw, "gitlab.com") {
- return "GitLab"
- } else if strings.Contains(raw, "github.com") {
- return "GitHub"
- } else {
- return "Outside"
- }
-}
diff --git a/internal/handlers/render.go b/internal/handlers/render.go
@@ -1,54 +0,0 @@
-package handlers
-
-import (
- "crypto/sha256"
- "fmt"
- "net/http"
- "os"
- "strings"
-
- "git.prime8.dev/moncheeta/prime8dev/internal/view"
-)
-
-func (h *Handler) renderTemplate(w http.ResponseWriter, r *http.Request, name string, data view.Data, maxAgeSeconds int) {
- if maxAgeSeconds < 0 {
- maxAgeSeconds = 0
- }
-
- body, err := h.renderer.Render(name, data)
- if err != nil {
- http.Error(w, "Failed to render content", http.StatusInternalServerError)
- return
- }
-
- sum := sha256.Sum256([]byte(body))
- etag := fmt.Sprintf(`W/"%x"`, sum)
- cacheEnabled := shouldCacheResponses()
-
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if !cacheEnabled {
- applyNoCacheHeaders(w)
- } else if maxAgeSeconds > 0 {
- w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAgeSeconds))
- w.Header().Set("ETag", etag)
- }
- w.Header().Set("Vary", "HX-Request")
-
- if cacheEnabled && maxAgeSeconds > 0 && strings.Contains(r.Header.Get("If-None-Match"), etag) {
- w.WriteHeader(http.StatusNotModified)
- return
- }
-
- _, _ = w.Write([]byte(body))
-}
-
-func shouldCacheResponses() bool {
- mode := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV")))
- return mode == "prod"
-}
-
-func applyNoCacheHeaders(w http.ResponseWriter) {
- w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
- w.Header().Set("Pragma", "no-cache")
- w.Header().Set("Expires", "0")
-}
diff --git a/internal/handlers/sitemap.go b/internal/handlers/sitemap.go
@@ -1,42 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "fmt"
- "net/http"
-
- "git.prime8.dev/moncheeta/prime8dev/internal/config"
- "git.prime8.dev/moncheeta/prime8dev/internal/content"
-)
-
-func (h *Handler) SitemapHandler(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/xml")
-
- var xml bytes.Buffer
- xml.WriteString(`<?xml version="1.0" encoding="UTF-8"?>
-<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
-
- blogs, _ := content.LoadBlogs()
- projects := config.GetProjects()
- experience := config.GetExperience()
-
- routes := []string{"/", "/projects", "/experience", "/blog"}
- for _, route := range routes {
- fmt.Fprintf(&xml, "<url><loc>%s%s</loc></url>", config.SiteURL, route)
- }
-
- for _, p := range projects {
- fmt.Fprintf(&xml, "<url><loc>%s/projects/%s</loc></url>", config.SiteURL, p.ID)
- }
-
- for _, exp := range experience {
- fmt.Fprintf(&xml, "<url><loc>%s/experience/%s</loc></url>", config.SiteURL, exp.ID)
- }
-
- for _, b := range blogs {
- fmt.Fprintf(&xml, "<url><loc>%s/blog/%s</loc></url>", config.SiteURL, b.ID)
- }
-
- xml.WriteString("</urlset>")
- _, _ = w.Write(xml.Bytes())
-}
diff --git a/internal/ssh/limiter.go b/internal/ssh/limiter.go
@@ -0,0 +1,74 @@
+package ssh
+
+import (
+ "sync"
+ "time"
+)
+
+type ipLimiter struct {
+ mu sync.Mutex
+ window time.Duration
+ max int
+ blockFor time.Duration
+ maxEntries int
+ entries map[string]ipEntry
+}
+
+type ipEntry struct {
+ count int
+ windowStart time.Time
+ blockedTill time.Time
+ lastSeen time.Time
+}
+
+func newIPLimiter(window time.Duration, max int, blockFor time.Duration, maxEntries int) *ipLimiter {
+ return &ipLimiter{
+ window: window,
+ max: max,
+ blockFor: blockFor,
+ maxEntries: maxEntries,
+ entries: make(map[string]ipEntry),
+ }
+}
+
+func (l *ipLimiter) Allow(ip string) (bool, time.Duration) {
+ now := time.Now()
+
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ if len(l.entries) > l.maxEntries {
+ l.compact(now)
+ }
+
+ entry := l.entries[ip]
+ entry.lastSeen = now
+
+ if entry.blockedTill.After(now) {
+ l.entries[ip] = entry
+ return false, entry.blockedTill.Sub(now)
+ }
+
+ if entry.windowStart.IsZero() || now.Sub(entry.windowStart) >= l.window {
+ entry.windowStart = now
+ entry.count = 0
+ }
+
+ entry.count++
+ if entry.count > l.max {
+ entry.blockedTill = now.Add(l.blockFor)
+ l.entries[ip] = entry
+ return false, l.blockFor
+ }
+
+ l.entries[ip] = entry
+ return true, 0
+}
+
+func (l *ipLimiter) compact(now time.Time) {
+ for ip, entry := range l.entries {
+ if now.Sub(entry.lastSeen) > (l.blockFor + l.window) {
+ delete(l.entries, ip)
+ }
+ }
+}
diff --git a/internal/ssh/network.go b/internal/ssh/network.go
@@ -0,0 +1,16 @@
+package ssh
+
+import "net"
+
+func remoteIP(addr net.Addr) string {
+ if addr == nil {
+ return "unknown"
+ }
+
+ host, _, err := net.SplitHostPort(addr.String())
+ if err != nil {
+ return addr.String()
+ }
+
+ return host
+}
diff --git a/internal/ssh/route.go b/internal/ssh/route.go
@@ -0,0 +1,42 @@
+package ssh
+
+import (
+ "io"
+ "log/slog"
+
+ charmssh "github.com/charmbracelet/ssh"
+ "github.com/charmbracelet/wish"
+)
+
+func routeMiddleware(logger *slog.Logger) wish.Middleware {
+ return func(next charmssh.Handler) charmssh.Handler {
+ return func(sess charmssh.Session) {
+ ip := remoteIP(sess.RemoteAddr())
+ user := sess.User()
+
+ if len(sess.Command()) > 0 {
+ _, _ = io.WriteString(sess.Stderr(), "remote command execution is disabled on the public interface\n")
+ logger.Info("route decision",
+ "user", user,
+ "ip", ip,
+ "result", "rejected",
+ "command", sess.RawCommand(),
+ )
+ _ = sess.Exit(126)
+ return
+ }
+
+ logger.Info("route decision",
+ "user", user,
+ "ip", ip,
+ "result", "accepted",
+ )
+ next(sess)
+ logger.Info("route decision",
+ "user", user,
+ "ip", ip,
+ "result", "exited",
+ )
+ }
+ }
+}
diff --git a/internal/ssh/server.go b/internal/ssh/server.go
@@ -0,0 +1,92 @@
+package ssh
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "time"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/tui"
+ charmssh "github.com/charmbracelet/ssh"
+ "github.com/charmbracelet/wish"
+ wishbubbletea "github.com/charmbracelet/wish/bubbletea"
+ gossh "golang.org/x/crypto/ssh"
+)
+
+const (
+ publicAddr = ":22"
+ hostKeyPath = ".ssh/public_ssh_ed25519"
+ idleTimeout = 90 * time.Second
+ maxTimeout = 10 * time.Minute
+ rateLimitWindow = 1 * time.Minute
+ rateLimitMax = 30
+ rateLimitBlock = 5 * time.Minute
+ rateLimitMaxIPs = 2048
+)
+
+func Run(ctx context.Context, logger *slog.Logger) error {
+ limiter := newIPLimiter(rateLimitWindow, rateLimitMax, rateLimitBlock, rateLimitMaxIPs)
+
+ server, err := wish.NewServer(
+ wish.WithAddress(publicAddr),
+ wish.WithHostKeyPath(hostKeyPath),
+ wish.WithIdleTimeout(idleTimeout),
+ wish.WithMaxTimeout(maxTimeout),
+ wish.WithPublicKeyAuth(func(_ charmssh.Context, _ charmssh.PublicKey) bool {
+ return true
+ }),
+ wish.WithKeyboardInteractiveAuth(func(_ charmssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
+ return true
+ }),
+ charmssh.WrapConn(func(_ charmssh.Context, conn net.Conn) net.Conn {
+ ip := remoteIP(conn.RemoteAddr())
+ allowed, retryAfter := limiter.Allow(ip)
+ if !allowed {
+ logger.Warn("connection rejected by rate limiter",
+ "ip", ip,
+ "retry_after", retryAfter.String(),
+ )
+ return nil
+ }
+ return conn
+ }),
+ func(s *charmssh.Server) error {
+ s.ConnectionFailedCallback = func(conn net.Conn, err error) {
+ logger.Warn("connection failed",
+ "ip", remoteIP(conn.RemoteAddr()),
+ "error", err,
+ )
+ }
+ return nil
+ },
+ wish.WithMiddleware(
+ wishbubbletea.Middleware(tui.SSHHandler),
+ routeMiddleware(logger),
+ ),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to initialize public ssh server: %w", err)
+ }
+
+ go func() {
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _ = server.Shutdown(shutdownCtx)
+ }()
+
+ logger.Info("public ssh server listening",
+ "addr", publicAddr,
+ "host_key", hostKeyPath,
+ )
+
+ err = server.ListenAndServe()
+ if err != nil && !errors.Is(err, charmssh.ErrServerClosed) {
+ return fmt.Errorf("public ssh server stopped with error: %w", err)
+ }
+
+ logger.Info("public ssh server stopped")
+ return nil
+}
diff --git a/internal/tui/about_ascii.go b/internal/tui/about_ascii.go
@@ -0,0 +1,44 @@
+package tui
+
+import (
+ "path/filepath"
+ "strings"
+
+ aic "github.com/TheZoraiz/ascii-image-converter/aic_package"
+)
+
+func (m portfolioModel) aboutASCIIFor(width int) string {
+ width = normalizeAboutASCIIWidth(width)
+
+ if m.aboutASCIICache != nil {
+ if ascii, ok := m.aboutASCIICache[width]; ok {
+ return ascii
+ }
+ }
+
+ return ""
+}
+
+func normalizeAboutASCIIWidth(width int) int {
+ width = max(width, 20)
+ if width > 56 {
+ return 56
+ }
+ return width
+}
+
+func loadAboutASCII(width int) string {
+ flags := aic.DefaultFlags()
+ flags.Width = width
+ flags.Colored = true
+ flags.Braille = false
+ flags.Dither = true
+ flags.Threshold = 160
+
+ ascii, err := aic.Convert(filepath.Join("static", "me.jpg"), flags)
+ if err != nil {
+ return ""
+ }
+
+ return strings.TrimRight(ascii, "\n")
+}
diff --git a/internal/tui/about_render.go b/internal/tui/about_render.go
@@ -0,0 +1,82 @@
+package tui
+
+import (
+ "strings"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/utils"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type aboutASCIIRenderedMsg struct {
+ width int
+ ascii string
+}
+
+type aboutNameRenderedMsg struct {
+ ascii string
+}
+
+func (m *portfolioModel) aboutASCIIWidthForLayout() (int, bool) {
+ bodyW := max(m.width, 20)
+ if bodyW < 72 {
+ return 0, false
+ }
+
+ leftW := min(max(bodyW/3, 26), 36)
+ return normalizeAboutASCIIWidth(leftW - 4), true
+}
+
+func (m *portfolioModel) maybeRenderAboutASCIICmd() tea.Cmd {
+ if m.activeTab != tabAbout {
+ return nil
+ }
+
+ width, ok := m.aboutASCIIWidthForLayout()
+ if !ok {
+ return nil
+ }
+
+ if _, cached := m.aboutASCIICache[width]; cached {
+ return nil
+ }
+ if m.aboutASCIILoads[width] {
+ return nil
+ }
+
+ m.aboutASCIILoads[width] = true
+ return renderAboutASCIICmd(width)
+}
+
+func (m *portfolioModel) maybeRenderAboutNameCmd() tea.Cmd {
+ if m.aboutNameReady || m.aboutNameLoading {
+ return nil
+ }
+
+ m.aboutNameLoading = true
+ name := m.about.Name
+ return func() tea.Msg {
+ return aboutNameRenderedMsg{ascii: utils.AboutNameASCII(name)}
+ }
+}
+
+func renderAboutASCIICmd(width int) tea.Cmd {
+ return func() tea.Msg {
+ return aboutASCIIRenderedMsg{
+ width: width,
+ ascii: loadAboutASCII(width),
+ }
+ }
+}
+
+func (m *portfolioModel) applyAboutASCIIRendered(msg aboutASCIIRenderedMsg) {
+ delete(m.aboutASCIILoads, msg.width)
+ m.aboutASCIICache[msg.width] = msg.ascii
+}
+
+func (m *portfolioModel) applyAboutNameRendered(msg aboutNameRenderedMsg) {
+ m.aboutNameLoading = false
+ m.aboutNameReady = true
+ if strings.TrimSpace(msg.ascii) != "" {
+ m.aboutNameASCII = msg.ascii
+ }
+}
diff --git a/internal/tui/blog_markdown_render.go b/internal/tui/blog_markdown_render.go
@@ -0,0 +1,86 @@
+package tui
+
+import (
+ "strconv"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type blogMarkdownRenderedMsg struct {
+ blogID string
+ width int
+ rendered string
+}
+
+func (m portfolioModel) cachedBlogMarkdown(blogID string, width int) (string, bool) {
+ byWidth, ok := m.blogMarkdownRender[blogID]
+ if !ok {
+ return "", false
+ }
+
+ rendered, ok := byWidth[width]
+ return rendered, ok
+}
+
+func (m *portfolioModel) setCachedBlogMarkdown(blogID string, width int, rendered string) {
+ byWidth := m.blogMarkdownRender[blogID]
+ if byWidth == nil {
+ byWidth = make(map[int]string)
+ m.blogMarkdownRender[blogID] = byWidth
+ }
+
+ byWidth[width] = rendered
+}
+
+func blogMarkdownLoadKey(blogID string, width int) string {
+ return blogID + ":" + strconv.Itoa(width)
+}
+
+func (m *portfolioModel) maybeRenderSelectedBlogCmd() tea.Cmd {
+ if m.activeTab != tabBlog || len(m.blogs) == 0 {
+ return nil
+ }
+
+ blog := m.blogs[m.blogIndex]
+ markdown := normalizeBlogMarkdown(blog.Markdown, blog.Description)
+ if strings.TrimSpace(markdown) == "" {
+ return nil
+ }
+
+ width := m.blogMarkdownWidth()
+ if _, ok := m.cachedBlogMarkdown(blog.ID, width); ok {
+ return nil
+ }
+
+ loadKey := blogMarkdownLoadKey(blog.ID, width)
+ if m.blogMarkdownLoads[loadKey] {
+ return nil
+ }
+
+ m.blogMarkdownLoads[loadKey] = true
+ return renderBlogMarkdownCmd(blog.ID, markdown, width)
+}
+
+func (m *portfolioModel) maybeRenderOpenBlogCmd() tea.Cmd {
+ if !m.blogOpen {
+ return nil
+ }
+
+ return m.maybeRenderSelectedBlogCmd()
+}
+
+func renderBlogMarkdownCmd(blogID string, markdown string, width int) tea.Cmd {
+ return func() tea.Msg {
+ rendered := renderMarkdown(markdown, width)
+ if rendered == "" {
+ rendered = markdown
+ }
+
+ return blogMarkdownRenderedMsg{
+ blogID: blogID,
+ width: width,
+ rendered: rendered,
+ }
+ }
+}
diff --git a/internal/tui/boot.go b/internal/tui/boot.go
@@ -0,0 +1,109 @@
+package tui
+
+import (
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+const (
+ bootChars = "\u2593\u2592\u2591\u2588\u2584\u2580\u25a0\u25a1\u25aa\u25ab\u25cf\u25cb\u25c6\u25c7"
+ bootStartDelay = 100 * time.Millisecond
+ bootScrambleDur = 400 * time.Millisecond
+ bootScrambleTick = 30 * time.Millisecond
+ bootHoldDur = 450 * time.Millisecond
+ bootFadeDur = 300 * time.Millisecond
+ bootTitleRevealEnd = bootStartDelay + bootScrambleDur
+ bootSubRevealEnd = bootTitleRevealEnd + bootScrambleDur
+ bootHoldEnd = bootSubRevealEnd + bootHoldDur
+ bootAnimationTotal = bootHoldEnd + bootFadeDur
+)
+
+var bootRuneSet = []rune(bootChars)
+
+type bootTickMsg time.Time
+
+func bootTickCmd() tea.Cmd {
+ return tea.Tick(bootScrambleTick, func(t time.Time) tea.Msg {
+ return bootTickMsg(t)
+ })
+}
+
+func (m *portfolioModel) syncBootFrame(now time.Time) {
+ if !m.bootActive {
+ return
+ }
+
+ elapsed := now.Sub(m.bootStartedAt)
+ if elapsed < 0 {
+ elapsed = 0
+ }
+
+ switch {
+ case elapsed < bootStartDelay:
+ m.bootFadeProgress = 0
+ m.bootTitleFrame = m.scrambleBootText(m.bootTitle)
+ m.bootSubtitleFrame = m.scrambleBootText(m.bootSubtitle)
+ case elapsed < bootTitleRevealEnd:
+ m.bootFadeProgress = 0
+ p := float64(elapsed-bootStartDelay) / float64(bootScrambleDur)
+ m.bootTitleFrame = m.revealBootText(m.bootTitle, p)
+ m.bootSubtitleFrame = m.scrambleBootText(m.bootSubtitle)
+ case elapsed < bootSubRevealEnd:
+ m.bootFadeProgress = 0
+ p := float64(elapsed-bootTitleRevealEnd) / float64(bootScrambleDur)
+ m.bootTitleFrame = m.bootTitle
+ m.bootSubtitleFrame = m.revealBootText(m.bootSubtitle, p)
+ case elapsed < bootHoldEnd:
+ m.bootFadeProgress = 0
+ m.bootTitleFrame = m.bootTitle
+ m.bootSubtitleFrame = m.bootSubtitle
+ case elapsed < bootAnimationTotal:
+ m.bootFadeProgress = float64(elapsed-bootHoldEnd) / float64(bootFadeDur)
+ m.bootTitleFrame = m.bootTitle
+ m.bootSubtitleFrame = m.bootSubtitle
+ default:
+ m.bootFadeProgress = 1
+ m.bootTitleFrame = m.bootTitle
+ m.bootSubtitleFrame = m.bootSubtitle
+ m.bootActive = false
+ }
+}
+
+func (m *portfolioModel) scrambleBootText(target string) string {
+ length := len([]rune(target))
+ if length == 0 {
+ return ""
+ }
+ out := make([]rune, length)
+ for i := 0; i < length; i++ {
+ out[i] = bootRuneSet[m.bootRand.Intn(len(bootRuneSet))]
+ }
+ return string(out)
+}
+
+func (m *portfolioModel) revealBootText(target string, progress float64) string {
+ r := []rune(target)
+ if len(r) == 0 {
+ return ""
+ }
+
+ if progress < 0 {
+ progress = 0
+ } else if progress > 1 {
+ progress = 1
+ }
+
+ revealCount := int(progress * float64(len(r)))
+ if revealCount > len(r) {
+ revealCount = len(r)
+ }
+
+ out := make([]rune, 0, len(r))
+ out = append(out, r[:revealCount]...)
+ for i := revealCount; i < len(r); i++ {
+ out = append(out, bootRuneSet[m.bootRand.Intn(len(bootRuneSet))])
+ }
+
+ return string(out)
+}
diff --git a/internal/tui/content.go b/internal/tui/content.go
@@ -0,0 +1,482 @@
+package tui
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ appcontent "git.prime8.dev/moncheeta/prime8dev/internal/content"
+ "github.com/charmbracelet/lipgloss"
+)
+
+func (m portfolioModel) aboutDetail() string {
+ job := "building and learning"
+ if m.currentJob != nil {
+ job = fmt.Sprintf("%s @ %s", m.currentJob.Role, m.currentJob.Company)
+ }
+
+ parts := []string{
+ renderPrompt(m.about.Handle, "whoami"),
+ m.aboutNameASCII,
+ m.about.Bio,
+ "",
+ renderPrompt(m.about.Handle, "cat info"),
+ m.infoNowTable(job),
+ "",
+ renderPrompt(m.about.Handle, "ls interests/"),
+ renderTagList(m.about.Interests),
+ "",
+ renderPrompt(m.about.Handle, "ls tools/"),
+ renderTagList(m.about.Tools),
+ "",
+ renderPrompt(m.about.Handle, "ls languages/"),
+ renderTagList(m.about.Languages),
+ "",
+ renderPrompt(m.about.Handle, "wakatime --week"),
+ m.aboutWeeklyStats(),
+ }
+
+ return strings.Join(parts, "\n")
+}
+
+func (m portfolioModel) aboutWeeklyStats() string {
+ if m.wakaWeekly == nil || len(m.wakaWeekly.Days) == 0 {
+ if m.wakaLoading || !m.wakaLoaded {
+ return dimStyle.Render("loading stats...")
+ }
+ return dimStyle.Render("stats unavailable")
+ }
+
+ barWidth := 24
+ lines := make([]string, 0, len(m.wakaWeekly.Days)+2)
+ lines = append(lines, rowStyle.Render("total: ")+infoStyle.Render(m.wakaWeekly.TotalText), "")
+
+ for _, day := range m.wakaWeekly.Days {
+ label := strings.ToLower(fitRunes(day.DayText, 3))
+ label = padRunesRight(label, 3)
+ percent := clampBarPercent(day.BarWidthPercent)
+ bar := renderASCIIBar(percent, barWidth)
+ line := dimStyle.Render(label+" ") + rowStyle.Render("|"+bar+"|") + dimStyle.Render(" "+day.TimeText)
+ lines = append(lines, line)
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+func clampBarPercent(percent string) int {
+ v := strings.TrimSpace(strings.TrimSuffix(percent, "%"))
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ return 0
+ }
+ if n < 0 {
+ return 0
+ }
+ if n > 100 {
+ return 100
+ }
+ return n
+}
+
+func renderASCIIBar(percent int, width int) string {
+ filled := (percent * width) / 100
+ if filled < 0 {
+ filled = 0
+ }
+ if filled > width {
+ filled = width
+ }
+ return strings.Repeat("#", filled) + strings.Repeat("-", width-filled)
+}
+
+func (m portfolioModel) infoNowTable(job string) string {
+ left := [][2]string{
+ {"location", m.about.Location},
+ {"school", m.about.School},
+ {"graduating", m.about.Graduating},
+ }
+
+ right := [][2]string{}
+ if m.currentJob != nil {
+ right = append(right,
+ [2]string{"company", m.currentJob.Company},
+ [2]string{"position", m.currentJob.Role},
+ )
+ } else {
+ right = append(right, [2]string{"current", job})
+ }
+
+ labelLW := 10
+ labelRW := 8
+ gap := 3
+ viewW, _ := m.detailSize()
+ available := max(viewW-2, 44)
+ valueBudget := available - (labelLW + 3) - gap - (labelRW + 3)
+ if valueBudget < 20 {
+ valueBudget = 20
+ }
+ valueLW := valueBudget / 2
+ valueRW := valueBudget - valueLW
+
+ rows := max(len(left), len(right))
+ lines := make([]string, 0, rows)
+ for i := 0; i < rows; i++ {
+ lk, lv := "", ""
+ if i < len(left) {
+ lk = left[i][0]
+ lv = fitRunes(left[i][1], valueLW)
+ }
+
+ rk, rv := "", ""
+ if i < len(right) {
+ rk = right[i][0]
+ rv = fitRunes(right[i][1], valueRW)
+ }
+
+ line := renderInfoCell(lk, lv, labelLW, valueLW)
+ if rk != "" || rv != "" {
+ line += strings.Repeat(" ", gap) + renderInfoCell(rk, rv, labelRW, valueRW)
+ }
+ lines = append(lines, strings.TrimRight(line, " "))
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+func renderInfoCell(key string, value string, keyWidth int, valueWidth int) string {
+ key = fitRunes(key, keyWidth)
+ value = fitRunes(value, valueWidth)
+ keyPadding := max(keyWidth-len([]rune(key)), 0)
+ keyText := key + ":" + strings.Repeat(" ", keyPadding+1)
+ valueText := fmt.Sprintf("%-*s", valueWidth, value)
+ return dimStyle.Render(keyText) + valueText
+}
+
+func (m portfolioModel) projectDetail() string {
+ if len(m.projects) == 0 {
+ return "No projects available."
+ }
+ p := m.projects[m.projectIndex]
+
+ if readme, ok := m.projectReadmes[p.ID]; ok && strings.TrimSpace(readme) != "" {
+ width := m.markdownWidth()
+ if rendered, ok := m.cachedProjectMarkdown(p.ID, width); ok && strings.TrimSpace(rendered) != "" {
+ return rendered
+ }
+
+ patched := replaceFirstH1WithSourceLink(readme, p.Name, p.URL)
+ fallback := patched
+ if tech := renderTagList(p.Tech); tech != "" {
+ fallback = insertTechRowBelowMarkdownH1(fallback, tech)
+ }
+
+ return fallback + "\n\n" + dimStyle.Render("formatting markdown...")
+ }
+
+ if m.projectReadmeLoads[p.ID] {
+ return m.projectReadmeFallback(p, infoStyle.Render("loading README..."))
+ }
+
+ if msg, ok := m.projectReadmeErrors[p.ID]; ok && msg != "" {
+ return m.projectReadmeFallback(p, dimStyle.Render("README unavailable: "+msg))
+ }
+
+ return m.projectReadmeFallback(p, infoStyle.Render("README not loaded yet."))
+}
+
+func (m portfolioModel) projectReadmeFallback(project appconfig.Project, status string) string {
+ headerMarkdown := replaceFirstH1WithSourceLink("", project.Name, project.URL)
+ header := strings.TrimSpace(renderMarkdown(headerMarkdown, m.projectMarkdownWidth()))
+ if header == "" {
+ header = titleStyle.Render(defaultProjectHeader(project.Name))
+ }
+
+ parts := []string{header}
+
+ if tech := renderTagList(project.Tech); tech != "" {
+ parts = append(parts, tech)
+ }
+
+ if strings.TrimSpace(status) != "" {
+ parts = append(parts, status)
+ }
+
+ return strings.Join(parts, "\n\n")
+}
+
+func defaultProjectHeader(name string) string {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return "Project Source"
+ }
+ return name
+}
+
+func insertTechRowBelowMarkdownH1(markdown string, techRow string) string {
+ if strings.TrimSpace(techRow) == "" {
+ return markdown
+ }
+
+ lines := strings.Split(markdown, "\n")
+ headIdx := -1
+ for i, line := range lines {
+ if strings.TrimSpace(line) != "" {
+ headIdx = i
+ break
+ }
+ }
+ if headIdx == -1 {
+ return techRow
+ }
+
+ if !strings.HasPrefix(strings.TrimSpace(lines[headIdx]), "# ") {
+ return techRow + "\n\n" + markdown
+ }
+
+ bodyStart := headIdx + 1
+ for bodyStart < len(lines) && strings.TrimSpace(lines[bodyStart]) == "" {
+ bodyStart++
+ }
+
+ header := strings.Join(lines[:headIdx+1], "\n")
+ if bodyStart >= len(lines) {
+ return header + "\n\n" + techRow
+ }
+
+ body := strings.Join(lines[bodyStart:], "\n")
+ return header + "\n\n" + techRow + "\n\n" + body
+}
+
+func replaceFirstH1WithSourceLink(markdown string, projectName string, projectURL string) string {
+ if projectName == "" {
+ projectName = "Project Source"
+ }
+
+ body := removeFirstMarkdownH1(markdown)
+
+ header := "# " + projectName
+ if strings.TrimSpace(projectURL) != "" {
+ header = "# [" + projectName + "](" + projectURL + ")"
+ }
+
+ if strings.TrimSpace(body) == "" {
+ return header
+ }
+
+ return header + "\n\n" + body
+}
+
+func removeFirstMarkdownH1(markdown string) string {
+ lines := strings.Split(markdown, "\n")
+ removed := false
+ out := make([]string, 0, len(lines))
+
+ for _, line := range lines {
+ if !removed && strings.HasPrefix(strings.TrimSpace(line), "# ") {
+ removed = true
+ continue
+ }
+ out = append(out, line)
+ }
+
+ return strings.TrimSpace(strings.Join(out, "\n"))
+}
+
+func (m portfolioModel) projectMarkdownWidth() int {
+ return m.markdownWidth()
+}
+
+func (m portfolioModel) markdownWidth() int {
+ w, _ := m.detailSize()
+ return max(w, 20)
+}
+
+func (m portfolioModel) experiencePage() string {
+ if len(m.experience) == 0 {
+ return "No experience available."
+ }
+
+ rows := []string{renderPrompt(m.about.Handle, "git log --oneline --graph --decorate")}
+ for i, e := range m.experience {
+ decorator := ""
+ if e.Current {
+ decorator = " (HEAD -> now)"
+ }
+ label := fmt.Sprintf("* %s %-20s %s%s", utilsYearLabel(e), fitRunes(e.Company, 20), fitRunes(e.Role, 22), decorator)
+ rangeLine := " " + dateRangeLabel(e)
+ if i == m.experienceIndex {
+ rows = append(rows, selectedRowStyle.Render("> "+label))
+ rows = append(rows, selectedRowStyle.Render(rangeLine))
+ for _, d := range e.Description {
+ rows = append(rows, rowStyle.Render(" | "+d))
+ }
+ if len(e.Tech) > 0 {
+ rows = append(rows, rowStyle.Render(" | ")+dimStyle.Render("tech: ")+renderTagList(e.Tech))
+ }
+ if e.URL != "" {
+ rows = append(rows, rowStyle.Render(" | ")+dimStyle.Render("url: ")+linkStyle.Render(e.URL))
+ }
+ } else {
+ rows = append(rows, rowStyle.Render(" "+label))
+ rows = append(rows, dimStyle.Render(rangeLine))
+ }
+ }
+ return strings.Join(rows, "\n")
+}
+
+func (m portfolioModel) blogDetail() string {
+ if len(m.blogs) == 0 {
+ return "~/blog is empty"
+ }
+ b := m.blogs[m.blogIndex]
+ parts := []string{renderPrompt(m.about.Handle, "cat ~/blog/"+b.ID+".md")}
+ parts = append(parts, renderBlogFrontmatter(b)...)
+ if m.blogOpen {
+ width := m.markdownWidth()
+ if rendered, ok := m.cachedBlogMarkdown(b.ID, width); ok && rendered != "" {
+ parts = append(parts, "", rendered)
+ } else {
+ fallback := normalizeBlogMarkdown(b.Markdown, b.Description)
+ if strings.TrimSpace(fallback) != "" {
+ parts = append(parts, "", fallback, "", dimStyle.Render("formatting markdown..."))
+ } else {
+ parts = append(parts, "", infoStyle.Render("rendering markdown..."))
+ }
+ }
+ }
+ return strings.Join(parts, "\n")
+}
+
+func renderBlogFrontmatter(b appcontent.Blog) []string {
+ lines := []string{
+ renderBlogMetaLine("title", b.Title, titleStyle),
+ renderBlogMetaLine("description", b.Description, rowStyle),
+ renderBlogMetaLine("date", b.Date.Format("2006-01-02"), infoStyle),
+ }
+
+ tags := strings.TrimSpace(renderTagList(b.Tags))
+ if tags != "" {
+ lines = append(lines, dimStyle.Render(padRunesRight("tags:", 12)+" ")+tags)
+ }
+
+ return lines
+}
+
+func renderBlogMetaLine(label string, value string, valueStyle lipgloss.Style) string {
+ key := dimStyle.Render(padRunesRight(label+":", 12) + " ")
+ return key + valueStyle.Render(value)
+}
+
+func normalizeBlogMarkdown(markdown string, description string) string {
+ lines := strings.Split(markdown, "\n")
+ for i := range lines {
+ trimmed := strings.TrimSpace(lines[i])
+ if trimmed == "" {
+ continue
+ }
+
+ if strings.EqualFold(trimmed, strings.TrimSpace(description)) {
+ lines[i] = "# " + trimmed
+ }
+ break
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+func (m portfolioModel) blogMarkdownWidth() int {
+ return m.markdownWidth()
+}
+
+func (m portfolioModel) blogPage() string {
+ if len(m.blogs) == 0 {
+ return "~/blog is empty"
+ }
+
+ if m.blogOpen {
+ return m.blogDetail()
+ }
+
+ modeW, dateW, nameW, descW := m.blogTableWidths()
+ markerW := 2
+ header := blogHeaderLine(markerW, modeW, dateW, nameW, descW)
+
+ rows := []string{
+ renderPrompt(m.about.Handle, "ls -l ~/blog"),
+ header,
+ }
+ for i, b := range m.blogs {
+ rows = append(rows, blogDataLine(i == m.blogIndex, b, markerW, modeW, dateW, nameW, descW))
+ }
+
+ return strings.Join(rows, "\n")
+}
+
+func (m portfolioModel) blogTableWidths() (int, int, int, int) {
+ viewW, _ := m.detailSize()
+ available := max(viewW-5, 24)
+
+ modeW := 10
+ dateW := 10
+ remaining := available - modeW - dateW - 3
+ if remaining < 14 {
+ nameW := max(4, remaining/2)
+ descW := max(4, remaining-nameW)
+ return modeW, dateW, nameW, descW
+ }
+
+ nameW := min(40, max(12, remaining/2))
+ descW := max(8, remaining-nameW)
+
+ maxName := 0
+ maxDesc := 0
+ for _, b := range m.blogs {
+ nameLen := len([]rune(b.ID + ".md"))
+ descLen := len([]rune(b.Description))
+ if nameLen > maxName {
+ maxName = nameLen
+ }
+ if descLen > maxDesc {
+ maxDesc = descLen
+ }
+ }
+
+ if maxName > 0 && maxName < nameW {
+ spare := nameW - maxName
+ nameW = maxName
+ descW += spare
+ }
+ if maxDesc > 0 && maxDesc < descW {
+ descW = maxDesc
+ }
+
+ return modeW, dateW, max(nameW, 8), max(descW, 8)
+}
+
+func blogHeaderLine(markerW int, modeW int, dateW int, nameW int, descW int) string {
+ marker := lipgloss.NewStyle().Width(markerW).Render("")
+ mode := dimStyle.Width(modeW).Render("mode")
+ date := dimStyle.Width(dateW).Render("date")
+ name := dimStyle.Width(nameW).Render("name")
+ desc := dimStyle.Width(descW).Render("desc")
+ return lipgloss.JoinHorizontal(lipgloss.Top, marker, mode, " ", date, " ", name, " ", desc)
+}
+
+func blogDataLine(selected bool, b appcontent.Blog, markerW int, modeW int, dateW int, nameW int, descW int) string {
+ marker := ""
+ if selected {
+ marker = ">"
+ }
+
+ markerCol := rowStyle.Width(markerW).Render(marker)
+ if selected {
+ markerCol = selectedRowStyle.Width(markerW).Render(marker)
+ }
+ modeCol := blogModeStyle.Width(modeW).Render("-rw-r--r--")
+ dateCol := dimStyle.Width(dateW).Render(b.Date.Format("2006-01-02"))
+ nameCol := blogNameStyle.Width(nameW).Render(fitRunes(b.ID+".md", nameW))
+ descCol := dimStyle.Width(descW).Render(fitRunes(b.Description, max(descW, 4)))
+
+ return lipgloss.JoinHorizontal(lipgloss.Top, markerCol, modeCol, " ", dateCol, " ", nameCol, " ", descCol)
+}
diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go
@@ -0,0 +1,153 @@
+package tui
+
+import (
+ "strings"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/utils"
+ "github.com/charmbracelet/lipgloss"
+)
+
+func wrapIndex(v int, size int) int {
+ if size == 0 {
+ return 0
+ }
+ for v < 0 {
+ v += size
+ }
+ return v % size
+}
+
+func fitRunes(s string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+
+ r := []rune(s)
+ if len(r) <= width {
+ return s
+ }
+
+ if width == 1 {
+ return "…"
+ }
+
+ return string(r[:width-1]) + "…"
+}
+
+func fitRunesLeading(s string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+
+ r := []rune(s)
+ if len(r) <= width {
+ return s
+ }
+
+ if width <= 3 {
+ return strings.Repeat(".", width)
+ }
+
+ return "..." + string(r[len(r)-(width-3):])
+}
+
+func padRunesRight(s string, width int) string {
+ r := []rune(s)
+ if len(r) >= width {
+ return s
+ }
+ return s + strings.Repeat(" ", width-len(r))
+}
+
+func renderKeyValueFixed(key string, value string, keyWidth int, valueWidth int, keyStyle lipgloss.Style, valueStyle lipgloss.Style) string {
+ keyText := padRunesRight(fitRunes(key, keyWidth), keyWidth) + ":"
+ valueText := padRunesRight(fitRunesLeading(value, valueWidth), valueWidth)
+ return keyStyle.Render(keyText) + " " + valueStyle.Render(valueText)
+}
+
+func renderKeyValueLine(key string, value string, totalWidth int, keyStyle lipgloss.Style, valueStyle lipgloss.Style) string {
+ keyLabel := key + ":"
+ valueWidth := max(totalWidth-len([]rune(keyLabel))-1, 1)
+ return keyStyle.Render(keyLabel) + " " + valueStyle.Render(fitRunesLeading(value, valueWidth))
+}
+
+func clipLines(s string, width int, maxLines int) string {
+ if maxLines <= 0 || width <= 0 {
+ return ""
+ }
+ return lipgloss.NewStyle().MaxWidth(width).MaxHeight(maxLines).Render(s)
+}
+
+func renderPanelWithStyle(style lipgloss.Style, width int, totalHeight int, content string) string {
+ borderX := style.GetHorizontalBorderSize()
+ borderY := style.GetVerticalBorderSize()
+ paddingX := style.GetHorizontalPadding()
+ paddingY := style.GetVerticalPadding()
+
+ innerWidth := max(width-borderX-paddingX, 1)
+ innerHeight := max(totalHeight-borderY-paddingY, 1)
+ clipped := clipLines(content, innerWidth, innerHeight)
+ return style.
+ Width(max(width-borderX, 1)).
+ Height(max(totalHeight-borderY, 1)).
+ Render(clipped)
+}
+
+func panelStyleSharedRight(style lipgloss.Style) lipgloss.Style {
+ b := lipgloss.NormalBorder()
+ b.TopRight = "┬"
+ b.BottomRight = "┴"
+ return style.BorderStyle(b)
+}
+
+func panelStyleSharedLeft(style lipgloss.Style) lipgloss.Style {
+ b := lipgloss.NormalBorder()
+ b.TopLeft = "┬"
+ b.BottomLeft = "┴"
+ return style.BorderStyle(b)
+}
+
+func panelStyleSharedBottom(style lipgloss.Style) lipgloss.Style {
+ b := lipgloss.NormalBorder()
+ b.BottomLeft = "├"
+ b.BottomRight = "┤"
+ return style.BorderStyle(b)
+}
+
+func panelStyleSharedTop(style lipgloss.Style) lipgloss.Style {
+ b := lipgloss.NormalBorder()
+ b.TopLeft = "├"
+ b.TopRight = "┤"
+ return style.BorderStyle(b)
+}
+
+func panelStyleWithoutRight(style lipgloss.Style) lipgloss.Style {
+ return style.Border(lipgloss.NormalBorder(), true, false, true, true)
+}
+
+func panelStyleWithoutLeft(style lipgloss.Style) lipgloss.Style {
+ return style.Border(lipgloss.NormalBorder(), true, true, true, false)
+}
+
+func panelStyleWithoutBottom(style lipgloss.Style) lipgloss.Style {
+ return style.Border(lipgloss.NormalBorder(), true, true, false, true)
+}
+
+func panelStyleWithoutTop(style lipgloss.Style) lipgloss.Style {
+ return style.Border(lipgloss.NormalBorder(), false, true, true, true)
+}
+
+func utilsYearLabel(exp config.Experience) string {
+ if exp.Current {
+ return "now"
+ }
+ if exp.EndDate != nil {
+ return exp.EndDate.Format("2006")
+ }
+ return exp.StartDate.Format("2006")
+}
+
+func dateRangeLabel(exp config.Experience) string {
+ return utils.FormatMonthYear(exp.StartDate) + " -> " + utils.FormatEndDate(exp.EndDate, exp.Current)
+}
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
@@ -0,0 +1,69 @@
+package tui
+
+import "github.com/charmbracelet/bubbles/key"
+
+type keyMap struct {
+ Up key.Binding
+ Down key.Binding
+ TabNext key.Binding
+ TabPrev key.Binding
+ Open key.Binding
+ Back key.Binding
+ Help key.Binding
+ Quit key.Binding
+ PageDown key.Binding
+ PageUp key.Binding
+}
+
+func defaultKeyMap() keyMap {
+ return keyMap{
+ Up: key.NewBinding(
+ key.WithKeys("up"),
+ key.WithHelp("↑", "up"),
+ ),
+ Down: key.NewBinding(
+ key.WithKeys("down"),
+ key.WithHelp("↓", "down"),
+ ),
+ TabNext: key.NewBinding(
+ key.WithKeys("tab", "right"),
+ key.WithHelp("tab/→", "next tab"),
+ ),
+ TabPrev: key.NewBinding(
+ key.WithKeys("shift+tab", "left"),
+ key.WithHelp("shift+tab/←", "prev tab"),
+ ),
+ Open: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "open/toggle"),
+ ),
+ Back: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "back/close"),
+ ),
+ Help: key.NewBinding(
+ key.WithKeys("?"),
+ key.WithHelp("?", "help"),
+ ),
+ Quit: key.NewBinding(
+ key.WithKeys("q", "ctrl+c"),
+ key.WithHelp("q", "quit"),
+ ),
+ PageDown: key.NewBinding(
+ key.WithKeys("pgdown", "f", "shift+down"),
+ key.WithHelp("pgdn", "scroll down"),
+ ),
+ PageUp: key.NewBinding(
+ key.WithKeys("pgup", "b", "shift+up"),
+ key.WithHelp("pgup", "scroll up"),
+ ),
+ }
+}
+
+func (k keyMap) ShortHelp() []key.Binding {
+ return []key.Binding{k.TabPrev, k.TabNext, k.Up, k.Down, k.Open, k.Back, k.Help, k.Quit}
+}
+
+func (k keyMap) FullHelp() [][]key.Binding {
+ return [][]key.Binding{{k.TabPrev, k.TabNext, k.Up, k.Down, k.PageUp, k.PageDown}, {k.Open, k.Back, k.Help, k.Quit}}
+}
diff --git a/internal/tui/layout.go b/internal/tui/layout.go
@@ -0,0 +1,67 @@
+package tui
+
+import "github.com/charmbracelet/lipgloss"
+
+func (m *portfolioModel) syncViewport() {
+ m.viewport.SetContent(m.detailContent())
+ w, h := m.detailSize()
+ m.viewport.Width = max(w, 1)
+ m.viewport.Height = max(h, 1)
+ m.viewport.GotoTop()
+}
+
+func (m portfolioModel) detailSize() (int, int) {
+ bodyW, bodyH := m.bodySize()
+
+ if m.activeTab == tabAbout {
+ if bodyW < 72 {
+ return max(bodyW-4, 10), max(bodyH-2, 3)
+ }
+ leftW := min(max(bodyW/3, 26), 36)
+ rightW := max(bodyW-leftW, 12)
+ return max(rightW-4, 10), max(bodyH-2, 3)
+ }
+
+ if m.activeTab == tabProjects {
+ if bodyW < 72 {
+ stackTop := max(bodyH/2, 3)
+ stackBottom := max(bodyH-stackTop, 1)
+ return max(bodyW-4, 10), max(stackBottom-2, 3)
+ }
+
+ sideW := m.sidebarWidth(bodyW)
+ detailPanelW := bodyW - sideW
+ return max(detailPanelW-4, 10), max(bodyH-2, 3)
+ }
+
+ return max(bodyW-4, 10), max(bodyH-2, 3)
+}
+
+func (m portfolioModel) bodySize() (int, int) {
+ w := max(m.width, 40)
+ h := max(m.height, 12)
+ headerH := lipgloss.Height(m.renderHeader(w))
+ footerH := lipgloss.Height(m.renderFooter(w))
+ bodyH := max(h-headerH-footerH, 3)
+ return max(w, 20), bodyH
+}
+
+func (m portfolioModel) sidebarWidth(bodyWidth int) int {
+ if bodyWidth < 80 {
+ return bodyWidth
+ }
+ return min(max(bodyWidth/3, 24), 40)
+}
+
+func (m portfolioModel) detailContent() string {
+ switch m.activeTab {
+ case tabAbout:
+ return m.aboutDetail()
+ case tabProjects:
+ return m.projectDetail()
+ case tabExperience:
+ return m.experiencePage()
+ default:
+ return m.blogPage()
+ }
+}
diff --git a/internal/tui/local.go b/internal/tui/local.go
@@ -0,0 +1,18 @@
+package tui
+
+import (
+ "fmt"
+ "os"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func RunLocal(user string) error {
+ model := newPortfolioModel(defaultUser(user))
+ p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithOutput(os.Stdout), tea.WithInputTTY())
+ if _, err := p.Run(); err != nil {
+ return fmt.Errorf("run local tui: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/tui/markdown.go b/internal/tui/markdown.go
@@ -0,0 +1,189 @@
+package tui
+
+import (
+ "regexp"
+ "strings"
+
+ termmarkdown "github.com/MichaelMure/go-term-markdown"
+)
+
+var (
+ ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+ urlRE = regexp.MustCompile(`https?://[^\s)]+`)
+ headingNumberTokenRE = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
+ horizontalRuleLineRE = regexp.MustCompile(`^\s*─+\s*$`)
+ alphaNumericRE = regexp.MustCompile(`[A-Za-z0-9]`)
+ ansiArtGlyphRE = regexp.MustCompile(`[█▓▒░▄▀▌▐▖▗▘▙▚▛▜▝▞▟⣿⣀-⣿]`)
+ markdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\([^\)]+\)`)
+ htmlImageRE = regexp.MustCompile(`(?i)<img\b`)
+)
+
+func renderMarkdown(markdown string, width int) string {
+ markdown = strings.TrimSpace(markdown)
+ if markdown == "" {
+ return ""
+ }
+
+ width = max(width, 20)
+ renderWidth := markdownRenderWidth(markdown, width)
+ out := termmarkdown.Render(markdown, renderWidth, 0)
+ if len(out) == 0 {
+ return markdown
+ }
+
+ return styleRenderedMarkdown(strings.TrimRight(string(out), "\n"))
+}
+
+func styleRenderedMarkdown(rendered string) string {
+ lines := strings.Split(rendered, "\n")
+ plainLines := make([]string, len(lines))
+ preserveANSI := make([]bool, len(lines))
+ for i := range lines {
+ plainLines[i] = ansiEscapeRE.ReplaceAllString(lines[i], "")
+ preserveANSI[i] = shouldPreserveANSILine(lines[i], plainLines[i])
+ }
+
+ headingOpen := false
+
+ for i := range lines {
+ if preserveANSI[i] {
+ continue
+ }
+
+ plainLine := plainLines[i]
+ trimmed := strings.TrimSpace(plainLine)
+ if trimmed == "" {
+ lines[i] = ""
+ headingOpen = false
+ continue
+ }
+ if horizontalRuleLineRE.MatchString(trimmed) {
+ lines[i] = dimStyle.Render(plainLine)
+ headingOpen = false
+ continue
+ }
+
+ line, headingStart := stripHeadingNumber(plainLine, plainLines, i)
+ if headingStart {
+ lines[i] = titleStyle.Render(line)
+ headingOpen = true
+ continue
+ }
+ if headingOpen {
+ lines[i] = titleStyle.Render(plainLine)
+ continue
+ }
+
+ lines[i] = styleBodyLine(plainLine)
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+func markdownRenderWidth(markdown string, width int) int {
+ if !markdownHasImages(markdown) {
+ return width
+ }
+
+ reduced := int(float64(width) * 0.72)
+ return max(min(reduced, width), 20)
+}
+
+func markdownHasImages(markdown string) bool {
+ return markdownImageRE.MatchString(markdown) || htmlImageRE.MatchString(markdown)
+}
+
+func shouldPreserveANSILine(raw string, plain string) bool {
+ if !ansiEscapeRE.MatchString(raw) {
+ return false
+ }
+
+ trimmed := strings.TrimSpace(plain)
+ if trimmed == "" {
+ return true
+ }
+ if alphaNumericRE.MatchString(trimmed) {
+ return false
+ }
+
+ return ansiArtGlyphRE.MatchString(trimmed)
+}
+
+func stripHeadingNumber(line string, lines []string, lineIdx int) (string, bool) {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" {
+ return line, false
+ }
+
+ parts := strings.Fields(trimmed)
+ if len(parts) < 2 {
+ return line, false
+ }
+
+ token := parts[0]
+ isOrderedList := strings.HasSuffix(token, ".")
+ isNestedHeading := headingNumberTokenRE.MatchString(token)
+ isTopLevelHeading := isDigitsOnly(token) && hasRuleBeforeBlank(lines, lineIdx)
+ if isOrderedList || (!isNestedHeading && !isTopLevelHeading) {
+ return line, false
+ }
+
+ leading := len(line) - len(strings.TrimLeft(line, " "))
+ body := strings.TrimLeft(line[leading:], " ")
+ if !strings.HasPrefix(body, token) {
+ return line, false
+ }
+
+ body = strings.TrimLeft(body[len(token):], " ")
+ return line[:leading] + body, true
+}
+
+func hasRuleBeforeBlank(lines []string, lineIdx int) bool {
+ for i := lineIdx + 1; i < len(lines); i++ {
+ trimmed := strings.TrimSpace(lines[i])
+ if trimmed == "" {
+ return false
+ }
+ if horizontalRuleLineRE.MatchString(trimmed) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func styleBodyLine(line string) string {
+ matches := urlRE.FindAllStringIndex(line, -1)
+ if len(matches) == 0 {
+ return rowStyle.Render(line)
+ }
+
+ var b strings.Builder
+ cursor := 0
+ for _, match := range matches {
+ if match[0] > cursor {
+ b.WriteString(rowStyle.Render(line[cursor:match[0]]))
+ }
+ b.WriteString(linkStyle.Render(line[match[0]:match[1]]))
+ cursor = match[1]
+ }
+ if cursor < len(line) {
+ b.WriteString(rowStyle.Render(line[cursor:]))
+ }
+
+ return b.String()
+}
+
+func isDigitsOnly(s string) bool {
+ if s == "" {
+ return false
+ }
+
+ for _, ch := range s {
+ if ch < '0' || ch > '9' {
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/internal/tui/program.go b/internal/tui/program.go
@@ -0,0 +1,32 @@
+package tui
+
+import (
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ ssh "github.com/charmbracelet/ssh"
+ wish "github.com/charmbracelet/wish/bubbletea"
+)
+
+func SSHHandler(sess ssh.Session) (tea.Model, []tea.ProgramOption) {
+ model := newPortfolioModel(defaultUser(sess.User()))
+ opts := wish.MakeOptions(sess)
+ opts = append(opts, tea.WithAltScreen())
+ return model, opts
+}
+
+func (m portfolioModel) Init() tea.Cmd {
+ return tea.Batch(
+ bootTickCmd(),
+ tickCmd(),
+ initWakaFooterCmd(),
+ )
+}
+
+func defaultUser(user string) string {
+ user = strings.TrimSpace(user)
+ if user == "" {
+ return "visitor"
+ }
+ return user
+}
diff --git a/internal/tui/project_markdown_render.go b/internal/tui/project_markdown_render.go
@@ -0,0 +1,195 @@
+package tui
+
+import (
+ "strconv"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type projectMarkdownRenderedMsg struct {
+ projectID string
+ width int
+ rendered string
+}
+
+func (m portfolioModel) cachedProjectMarkdown(projectID string, width int) (string, bool) {
+ byWidth, ok := m.projectReadmeRender[projectID]
+ if !ok {
+ return "", false
+ }
+
+ rendered, ok := byWidth[width]
+ return rendered, ok
+}
+
+func (m *portfolioModel) setCachedProjectMarkdown(projectID string, width int, rendered string) {
+ byWidth := m.projectReadmeRender[projectID]
+ if byWidth == nil {
+ byWidth = make(map[int]string)
+ m.projectReadmeRender[projectID] = byWidth
+ }
+
+ byWidth[width] = rendered
+}
+
+func projectMarkdownLoadKey(projectID string, width int) string {
+ return projectID + ":" + strconv.Itoa(width)
+}
+
+func (m *portfolioModel) maybeRenderSelectedProjectMarkdownCmd() tea.Cmd {
+ if m.activeTab != tabProjects {
+ return nil
+ }
+
+ project := m.selectedProject()
+ if project == nil {
+ return nil
+ }
+
+ readme, ok := m.projectReadmes[project.ID]
+ if !ok || strings.TrimSpace(readme) == "" {
+ return nil
+ }
+
+ width := m.projectMarkdownWidth()
+ if _, ok := m.cachedProjectMarkdown(project.ID, width); ok {
+ return nil
+ }
+
+ loadKey := projectMarkdownLoadKey(project.ID, width)
+ if m.projectMarkdownLoads[loadKey] {
+ return nil
+ }
+
+ tagRow := renderTagList(project.Tech)
+ patched := replaceFirstH1WithSourceLink(readme, project.Name, project.URL)
+
+ m.projectMarkdownLoads[loadKey] = true
+ return renderProjectMarkdownCmd(project.ID, patched, tagRow, width)
+}
+
+func renderProjectMarkdownCmd(projectID string, markdown string, techRow string, width int) tea.Cmd {
+ return func() tea.Msg {
+ rendered := renderMarkdown(markdown, width)
+ if rendered == "" {
+ rendered = markdown
+ }
+ rendered = outdentRenderedBody(rendered)
+ if strings.TrimSpace(techRow) != "" {
+ rendered = insertTechRowBelowRenderedH1(rendered, techRow)
+ }
+
+ return projectMarkdownRenderedMsg{
+ projectID: projectID,
+ width: width,
+ rendered: rendered,
+ }
+ }
+}
+
+func insertTechRowBelowRenderedH1(rendered string, techRow string) string {
+ if strings.TrimSpace(techRow) == "" {
+ return rendered
+ }
+
+ lines := strings.Split(rendered, "\n")
+ headIdx := -1
+ for i, line := range lines {
+ plain := strings.TrimSpace(ansiEscapeRE.ReplaceAllString(line, ""))
+ if plain != "" {
+ headIdx = i
+ break
+ }
+ }
+ if headIdx == -1 {
+ if strings.TrimSpace(rendered) == "" {
+ return techRow
+ }
+ return techRow + "\n\n" + rendered
+ }
+
+ insertAt := headingBlockEndIndex(lines, headIdx)
+
+ bodyStart := insertAt
+ for bodyStart < len(lines) && strings.TrimSpace(ansiEscapeRE.ReplaceAllString(lines[bodyStart], "")) == "" {
+ bodyStart++
+ }
+
+ header := strings.Join(lines[:insertAt], "\n")
+ if bodyStart >= len(lines) {
+ if strings.TrimSpace(header) == "" {
+ return techRow
+ }
+ return header + "\n" + techRow
+ }
+
+ body := strings.Join(lines[bodyStart:], "\n")
+ return header + "\n" + techRow + "\n\n" + body
+}
+
+func headingBlockEndIndex(lines []string, headIdx int) int {
+ for i := headIdx + 1; i < len(lines); i++ {
+ plain := strings.TrimSpace(ansiEscapeRE.ReplaceAllString(lines[i], ""))
+ if horizontalRuleLineRE.MatchString(plain) {
+ return i + 1
+ }
+ if plain == "" {
+ return i
+ }
+ }
+ return len(lines)
+}
+
+func outdentRenderedBody(rendered string) string {
+ lines := strings.Split(rendered, "\n")
+ if len(lines) < 2 {
+ return rendered
+ }
+
+ headIdx := -1
+ for i, line := range lines {
+ if strings.TrimSpace(line) != "" {
+ headIdx = i
+ break
+ }
+ }
+ if headIdx == -1 || headIdx == len(lines)-1 {
+ return rendered
+ }
+
+ bodyStart := headIdx + 1
+ for bodyStart < len(lines) && strings.TrimSpace(lines[bodyStart]) == "" {
+ bodyStart++
+ }
+ if bodyStart >= len(lines) {
+ return rendered
+ }
+
+ outdent := 0
+ for _, line := range lines[bodyStart:] {
+ if strings.TrimSpace(line) == "" || !strings.HasPrefix(line, " ") {
+ continue
+ }
+ indent := 0
+ for indent < len(line) && line[indent] == ' ' {
+ indent++
+ }
+ if outdent == 0 || indent < outdent {
+ outdent = indent
+ }
+ }
+ if outdent == 0 {
+ return rendered
+ }
+
+ for i := bodyStart; i < len(lines); i++ {
+ indent := 0
+ for indent < len(lines[i]) && indent < outdent && lines[i][indent] == ' ' {
+ indent++
+ }
+ lines[i] = lines[i][indent:]
+ }
+
+ return strings.Join(lines, "\n")
+}
diff --git a/internal/tui/projects_readme.go b/internal/tui/projects_readme.go
@@ -0,0 +1,88 @@
+package tui
+
+import (
+ "fmt"
+
+ appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ appcontent "git.prime8.dev/moncheeta/prime8dev/internal/content"
+ "git.prime8.dev/moncheeta/prime8dev/internal/external"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type projectReadmeMsg struct {
+ projectID string
+ readme string
+ err error
+}
+
+func (m portfolioModel) selectedProject() *appconfig.Project {
+ if len(m.projects) == 0 {
+ return nil
+ }
+ if m.projectIndex < 0 || m.projectIndex >= len(m.projects) {
+ return nil
+ }
+ return &m.projects[m.projectIndex]
+}
+
+func (m *portfolioModel) maybeLoadProjectReadmeCmd() tea.Cmd {
+ if m.activeTab != tabProjects {
+ return nil
+ }
+
+ project := m.selectedProject()
+ if project == nil {
+ return nil
+ }
+
+ id := project.ID
+ if _, ok := m.projectReadmes[id]; ok {
+ return nil
+ }
+ if m.projectReadmeLoads[id] {
+ return nil
+ }
+ m.projectReadmeLoads[id] = true
+ return fetchProjectReadmeCmd(m.gitLabService, *project)
+}
+
+func (m *portfolioModel) maybePrefetchProjectReadmesCmd() tea.Cmd {
+ if m.activeTab != tabProjects {
+ return nil
+ }
+ if m.projectPrefetchDone {
+ return nil
+ }
+ cmds := make([]tea.Cmd, 0, len(m.projects))
+ for _, project := range m.projects {
+ id := project.ID
+ if _, ok := m.projectReadmes[id]; ok {
+ continue
+ }
+ if m.projectReadmeLoads[id] {
+ continue
+ }
+ m.projectReadmeLoads[id] = true
+ cmds = append(cmds, fetchProjectReadmeCmd(m.gitLabService, project))
+ }
+
+ m.projectPrefetchDone = true
+ if len(cmds) == 0 {
+ return nil
+ }
+
+ return tea.Batch(cmds...)
+}
+
+func fetchProjectReadmeCmd(service *external.GitLab, project appconfig.Project) tea.Cmd {
+ return func() tea.Msg {
+ current, err := appcontent.GetProjectByID(service, project.ID)
+ if err != nil {
+ return projectReadmeMsg{projectID: project.ID, err: err}
+ }
+ if current == nil || current.Markdown == "" {
+ return projectReadmeMsg{projectID: project.ID, err: fmt.Errorf("readme unavailable")}
+ }
+ return projectReadmeMsg{projectID: project.ID, readme: current.Markdown}
+ }
+}
diff --git a/internal/tui/styles.go b/internal/tui/styles.go
@@ -0,0 +1,94 @@
+package tui
+
+import "github.com/charmbracelet/lipgloss"
+
+var (
+ colorFg = lipgloss.Color("250")
+ colorDim = lipgloss.Color("242")
+ colorBorder = lipgloss.Color("239")
+ colorAccent = lipgloss.Color("179")
+ colorBlue = lipgloss.Color("74")
+ colorWebBlue = lipgloss.Color("68")
+ colorLightBlue = lipgloss.Color("75")
+ colorCyan = lipgloss.Color("73")
+ colorMagenta = lipgloss.Color("133")
+ colorFooterBg = lipgloss.Color("235")
+
+ headerStyle = lipgloss.NewStyle().
+ BorderBottom(true).
+ BorderForeground(colorBorder).
+ Foreground(colorFg).
+ Padding(0, 1)
+
+ footerStyle = lipgloss.NewStyle().
+ BorderTop(true).
+ BorderForeground(colorBorder).
+ Background(colorFooterBg).
+ Foreground(colorDim).
+ Padding(0, 1)
+
+ panelStyle = lipgloss.NewStyle().
+ Border(lipgloss.NormalBorder()).
+ BorderForeground(colorBorder).
+ Padding(0, 1)
+
+ panelActiveStyle = panelStyle.
+ BorderForeground(colorAccent)
+
+ panelTitleStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
+ bootTitleStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
+ bootSubtitleStyle = lipgloss.NewStyle().
+ Foreground(colorDim)
+
+ promptStyle = lipgloss.NewStyle().Foreground(colorDim)
+ promptUserStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("71"))
+ promptHostStyle = lipgloss.NewStyle().Foreground(colorAccent)
+ promptPathStyle = lipgloss.NewStyle().Foreground(colorCyan)
+ promptDollarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("71"))
+ promptCmdStyle = lipgloss.NewStyle().Foreground(colorFg)
+
+ selectedRowStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
+ tabActiveStyle = selectedRowStyle
+ rowStyle = lipgloss.NewStyle().Foreground(colorFg)
+ dimStyle = lipgloss.NewStyle().Foreground(colorDim)
+ titleStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
+ linkStyle = lipgloss.NewStyle().Foreground(colorBlue)
+ blogModeStyle = lipgloss.NewStyle().Foreground(colorWebBlue)
+ blogNameStyle = lipgloss.NewStyle().Foreground(colorLightBlue)
+ infoStyle = lipgloss.NewStyle().Foreground(colorCyan)
+ footerTimeStyle = lipgloss.NewStyle().Foreground(colorCyan).Background(colorFooterBg)
+ footerWakaStyle = lipgloss.NewStyle().Foreground(colorMagenta).Background(colorFooterBg)
+ footerDimStyle = lipgloss.NewStyle().Foreground(colorDim).Background(colorFooterBg)
+
+ tagDefaultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("233")).Background(lipgloss.Color("242")).Padding(0, 1)
+ tagStyles = map[string]lipgloss.Style{
+ "lang-zig": lipgloss.NewStyle().Background(lipgloss.Color("#f7a41d")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "lang-rust": lipgloss.NewStyle().Background(lipgloss.Color("#dea584")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "lang-c": lipgloss.NewStyle().Background(lipgloss.Color("#a8b1c2")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "lang-go": lipgloss.NewStyle().Background(lipgloss.Color("#00add8")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "lang-ts": lipgloss.NewStyle().Background(lipgloss.Color("#3178c6")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "tech-astro": lipgloss.NewStyle().Background(lipgloss.Color("#ff5d01")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "lang-python": lipgloss.NewStyle().Background(lipgloss.Color("#f4c542")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "tool-zed": lipgloss.NewStyle().Background(lipgloss.Color("#084ccf")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "tool-neovim": lipgloss.NewStyle().Background(lipgloss.Color("#57a143")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "tool-opencode": lipgloss.NewStyle().Background(lipgloss.Color("#af5faf")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "tool-linux": lipgloss.NewStyle().Background(lipgloss.Color("#f7c94c")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "tool-fish": lipgloss.NewStyle().Background(lipgloss.Color("#d78700")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "tool-ghostty": lipgloss.NewStyle().Background(lipgloss.Color("#8b5cf6")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "interest-lowlevel": lipgloss.NewStyle().Background(lipgloss.Color("#d75f5f")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "interest-tui": lipgloss.NewStyle().Background(lipgloss.Color("#5faf5f")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "interest-embedded": lipgloss.NewStyle().Background(lipgloss.Color("#5fafaf")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "interest-unix": lipgloss.NewStyle().Background(lipgloss.Color("#d7af5f")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "interest-webdev": lipgloss.NewStyle().Background(lipgloss.Color("#2d7d9a")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "tech-temporal": lipgloss.NewStyle().Background(lipgloss.Color("#4c35c9")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "tech-postgres": lipgloss.NewStyle().Background(lipgloss.Color("#336791")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "tech-pytorch": lipgloss.NewStyle().Background(lipgloss.Color("#ee4c2c")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "tech-cuda": lipgloss.NewStyle().Background(lipgloss.Color("#76b900")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "tech-docker": lipgloss.NewStyle().Background(lipgloss.Color("#2496ed")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "tech-kafka": lipgloss.NewStyle().Background(lipgloss.Color("#4c35c9")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "tech-rn": lipgloss.NewStyle().Background(lipgloss.Color("#61dafb")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "tech-supabase": lipgloss.NewStyle().Background(lipgloss.Color("#3ecf8e")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ "tech-rest": lipgloss.NewStyle().Background(lipgloss.Color("#6b8e23")).Foreground(lipgloss.Color("#ffffff")).Padding(0, 1),
+ "project-portfolio": lipgloss.NewStyle().Background(lipgloss.Color("#b07a2f")).Foreground(lipgloss.Color("#0d0d0d")).Padding(0, 1),
+ }
+)
diff --git a/internal/tui/tags.go b/internal/tui/tags.go
@@ -0,0 +1,89 @@
+package tui
+
+import "strings"
+
+func renderTag(label string) string {
+ style, ok := tagStyles[tagClassKey(label)]
+ if !ok {
+ style = tagDefaultStyle
+ }
+ return style.Render(label)
+}
+
+func renderTagList(labels []string) string {
+ if len(labels) == 0 {
+ return ""
+ }
+ out := make([]string, 0, len(labels))
+ for _, label := range labels {
+ if strings.TrimSpace(label) == "" {
+ continue
+ }
+ out = append(out, renderTag(label))
+ }
+ return strings.Join(out, " ")
+}
+
+func tagClassKey(label string) string {
+ v := strings.ToLower(strings.TrimSpace(label))
+ switch v {
+ case "zig":
+ return "lang-zig"
+ case "rust":
+ return "lang-rust"
+ case "c":
+ return "lang-c"
+ case "go", "golang":
+ return "lang-go"
+ case "typescript", "ts":
+ return "lang-ts"
+ case "astro":
+ return "tech-astro"
+ case "python":
+ return "lang-python"
+ case "zed", "zed (vim mode)":
+ return "tool-zed"
+ case "neovim":
+ return "tool-neovim"
+ case "opencode":
+ return "tool-opencode"
+ case "linux":
+ return "tool-linux"
+ case "fish":
+ return "tool-fish"
+ case "ghostty":
+ return "tool-ghostty"
+ case "low-level systems", "low level systems":
+ return "interest-lowlevel"
+ case "tui/cli tooling", "tui cli tooling", "tui":
+ return "interest-tui"
+ case "embedded", "embedded systems":
+ return "interest-embedded"
+ case "unix philosophy", "unix":
+ return "interest-unix"
+ case "web development", "webdev", "web dev":
+ return "interest-webdev"
+ case "temporal":
+ return "tech-temporal"
+ case "postgres", "postgresql":
+ return "tech-postgres"
+ case "pytorch":
+ return "tech-pytorch"
+ case "cuda":
+ return "tech-cuda"
+ case "docker":
+ return "tech-docker"
+ case "kafka":
+ return "tech-kafka"
+ case "react native", "react-native", "rn":
+ return "tech-rn"
+ case "supabase":
+ return "tech-supabase"
+ case "rest":
+ return "tech-rest"
+ case "portfolio", "portfolio site":
+ return "project-portfolio"
+ default:
+ return ""
+ }
+}
diff --git a/internal/tui/types.go b/internal/tui/types.go
@@ -0,0 +1,141 @@
+package tui
+
+import (
+ "math/rand"
+ "time"
+
+ appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ appcontent "git.prime8.dev/moncheeta/prime8dev/internal/content"
+ "git.prime8.dev/moncheeta/prime8dev/internal/external"
+ "github.com/charmbracelet/bubbles/help"
+ "github.com/charmbracelet/bubbles/viewport"
+ "github.com/patrickmn/go-cache"
+)
+
+const (
+ tabAbout = iota
+ tabProjects
+ tabExperience
+ tabBlog
+
+ minWindowWidth = 80
+ minWindowHeight = 24
+)
+
+var tabLabels = []string{"about", "projects", "experience", "blog"}
+
+type tickMsg time.Time
+
+type portfolioModel struct {
+ user string
+ about appconfig.About
+ currentJob *appconfig.Experience
+ aboutNameASCII string
+ aboutNameReady bool
+ aboutNameLoading bool
+ aboutASCIICache map[int]string
+ aboutASCIILoads map[int]bool
+
+ projects []appconfig.Project
+ experience []appconfig.Experience
+ blogs []appcontent.Blog
+
+ activeTab int
+ projectIndex int
+ projectReadmeScroll bool
+ experienceIndex int
+ blogIndex int
+ blogOpen bool
+
+ width int
+ height int
+ now time.Time
+
+ bootTitle string
+ bootSubtitle string
+ bootTitleFrame string
+ bootSubtitleFrame string
+ bootStartedAt time.Time
+ bootFadeProgress float64
+ bootActive bool
+ bootRand *rand.Rand
+
+ keys keyMap
+ help help.Model
+ viewport viewport.Model
+ gitLabService *external.GitLab
+
+ projectReadmes map[string]string
+ projectReadmeErrors map[string]string
+ projectReadmeLoads map[string]bool
+ projectReadmeRender map[string]map[int]string
+ projectMarkdownLoads map[string]bool
+ projectPrefetchDone bool
+
+ blogMarkdownRender map[string]map[int]string
+ blogMarkdownLoads map[string]bool
+
+ wakaHours string
+ wakaWeekly *external.WeeklyStats
+ wakaLoaded bool
+ wakaLoading bool
+ wakaTime *external.WakaTime
+}
+
+func newPortfolioModel(user string) portfolioModel {
+ gitLabService := external.NewGitLab()
+
+ about := appconfig.GetAbout()
+ exp := appconfig.GetExperience()
+ blogs, _ := appcontent.LoadBlogs()
+ projects, _ := appcontent.LoadProjects(gitLabService)
+ bootTitle := about.Name
+ if bootTitle == "" {
+ bootTitle = "prime8.dev"
+ }
+ vp := viewport.New(1, 1)
+ vp.SetContent("loading...")
+
+ h := help.New()
+ h.ShowAll = false
+
+ m := portfolioModel{
+ gitLabService: gitLabService,
+ user: user,
+ about: about,
+ currentJob: appconfig.CurrentJob(exp),
+ aboutNameASCII: about.Name,
+ aboutNameReady: false,
+ aboutNameLoading: false,
+ aboutASCIICache: make(map[int]string),
+ aboutASCIILoads: make(map[int]bool),
+ projects: projects,
+ experience: exp,
+ blogs: blogs,
+ now: time.Now(),
+ width: 80,
+ height: 24,
+ bootTitle: bootTitle,
+ bootSubtitle: "www.prime8.dev",
+ bootStartedAt: time.Now(),
+ bootActive: true,
+ bootRand: rand.New(rand.NewSource(time.Now().UnixNano())),
+ keys: defaultKeyMap(),
+ help: h,
+ viewport: vp,
+
+ projectReadmes: make(map[string]string),
+ projectReadmeErrors: make(map[string]string),
+ projectReadmeLoads: make(map[string]bool),
+ projectReadmeRender: make(map[string]map[int]string),
+ projectMarkdownLoads: make(map[string]bool),
+
+ blogMarkdownRender: make(map[string]map[int]string),
+ blogMarkdownLoads: make(map[string]bool),
+ wakaTime: external.NewWakaTime(cache.New(5*time.Minute, 10*time.Minute)),
+ }
+ m.syncBootFrame(m.bootStartedAt)
+ m.syncViewport()
+
+ return m
+}
diff --git a/internal/tui/update.go b/internal/tui/update.go
@@ -0,0 +1,316 @@
+package tui
+
+import (
+ "time"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/viewport"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func tickCmd() tea.Cmd {
+ return tea.Tick(time.Second, func(t time.Time) tea.Msg {
+ return tickMsg(t)
+ })
+}
+
+func (m portfolioModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ m.syncViewport()
+ return m, m.refreshDataCmd()
+ case tickMsg:
+ m.now = time.Time(msg)
+ return m, tea.Batch(tickCmd(), m.refreshDataCmd())
+ case bootTickMsg:
+ if !m.bootActive {
+ return m, nil
+ }
+ m.syncBootFrame(time.Time(msg))
+ if !m.bootActive {
+ return m, nil
+ }
+ return m, bootTickCmd()
+ case wakaFooterInitMsg:
+ return m, m.maybeLoadWakaFooterCmd()
+ case projectReadmeMsg:
+ m.projectReadmeLoads[msg.projectID] = false
+ cmd := tea.Cmd(nil)
+ if msg.err != nil {
+ m.projectReadmeErrors[msg.projectID] = msg.err.Error()
+ } else {
+ m.projectReadmes[msg.projectID] = msg.readme
+ delete(m.projectReadmeErrors, msg.projectID)
+ delete(m.projectReadmeRender, msg.projectID)
+ cmd = m.maybeRenderSelectedProjectMarkdownCmd()
+ }
+ if m.activeTab == tabProjects {
+ if selected := m.selectedProject(); selected != nil && selected.ID == msg.projectID {
+ m.syncViewport()
+ }
+ }
+ return m, cmd
+ case projectMarkdownRenderedMsg:
+ delete(m.projectMarkdownLoads, projectMarkdownLoadKey(msg.projectID, msg.width))
+ m.setCachedProjectMarkdown(msg.projectID, msg.width, msg.rendered)
+ if m.activeTab == tabProjects {
+ if selected := m.selectedProject(); selected != nil && selected.ID == msg.projectID {
+ m.syncViewport()
+ }
+ }
+ return m, nil
+ case blogMarkdownRenderedMsg:
+ delete(m.blogMarkdownLoads, blogMarkdownLoadKey(msg.blogID, msg.width))
+ m.setCachedBlogMarkdown(msg.blogID, msg.width, msg.rendered)
+ if m.activeTab == tabBlog && m.blogOpen {
+ if m.blogIndex >= 0 && m.blogIndex < len(m.blogs) && m.blogs[m.blogIndex].ID == msg.blogID {
+ m.syncViewport()
+ }
+ }
+ return m, nil
+ case aboutASCIIRenderedMsg:
+ m.applyAboutASCIIRendered(msg)
+ if m.activeTab == tabAbout {
+ m.syncViewport()
+ }
+ return m, nil
+ case aboutNameRenderedMsg:
+ m.applyAboutNameRendered(msg)
+ if m.activeTab == tabAbout {
+ m.syncViewport()
+ }
+ return m, nil
+ case wakaFooterMsg:
+ m.wakaLoading = false
+ m.wakaLoaded = true
+ m.wakaWeekly = msg.weekly
+ if msg.ok {
+ m.wakaHours = msg.hours
+ } else {
+ m.wakaHours = ""
+ }
+ if m.activeTab == tabAbout {
+ m.syncViewport()
+ }
+ return m, nil
+ case tea.KeyMsg:
+ return m, m.handleKeyMsg(msg)
+ }
+
+ return m, nil
+}
+
+func (m *portfolioModel) refreshDataCmd() tea.Cmd {
+ return tea.Batch(
+ m.maybeLoadProjectReadmeCmd(),
+ m.maybePrefetchProjectReadmesCmd(),
+ m.maybeRenderSelectedProjectMarkdownCmd(),
+ m.maybeRenderSelectedBlogCmd(),
+ m.maybeLoadWakaFooterCmd(),
+ m.maybeRenderAboutASCIICmd(),
+ m.maybeRenderAboutNameCmd(),
+ )
+}
+
+func (m *portfolioModel) handleKeyMsg(msg tea.KeyMsg) tea.Cmd {
+ if m.bootActive {
+ if key.Matches(msg, m.keys.Quit) {
+ return tea.Quit
+ }
+ return nil
+ }
+
+ if m.help.ShowAll {
+ switch {
+ case key.Matches(msg, m.keys.Quit):
+ return tea.Quit
+ case key.Matches(msg, m.keys.Help), key.Matches(msg, m.keys.Back):
+ m.help.ShowAll = false
+ return nil
+ default:
+ return nil
+ }
+ }
+
+ if m.consumeProjectReadmeBack(msg) {
+ return nil
+ }
+
+ if m.consumeBlogBack(msg) {
+ return nil
+ }
+
+ if tab, ok := tabIndexFromKey(msg.String()); ok {
+ return m.activateTab(tab)
+ }
+
+ switch {
+ case key.Matches(msg, m.keys.Quit):
+ return tea.Quit
+ case key.Matches(msg, m.keys.Help):
+ m.help.ShowAll = !m.help.ShowAll
+ return nil
+ case key.Matches(msg, m.keys.TabNext):
+ return m.activateTab(wrapIndex(m.activeTab+1, len(tabLabels)))
+ case key.Matches(msg, m.keys.TabPrev):
+ return m.activateTab(wrapIndex(m.activeTab-1, len(tabLabels)))
+ case key.Matches(msg, m.keys.Open):
+ return m.handleOpenKey()
+ case key.Matches(msg, m.keys.Back):
+ return m.handleBackKey()
+ case key.Matches(msg, m.keys.Up):
+ return tea.Batch(m.moveSelection(-1), m.refreshDataCmd())
+ case key.Matches(msg, m.keys.Down):
+ return tea.Batch(m.moveSelection(1), m.refreshDataCmd())
+ case key.Matches(msg, m.keys.PageUp):
+ if m.canPageScroll() {
+ m.viewport.HalfViewUp()
+ }
+ return nil
+ case key.Matches(msg, m.keys.PageDown):
+ if m.canPageScroll() {
+ m.viewport.HalfViewDown()
+ }
+ return nil
+ default:
+ return nil
+ }
+}
+
+func (m *portfolioModel) activateTab(tab int) tea.Cmd {
+ if tab < 0 || tab >= len(tabLabels) {
+ return nil
+ }
+
+ m.activeTab = tab
+ m.projectReadmeScroll = false
+ m.blogOpen = false
+ m.syncViewport()
+
+ return m.refreshDataCmd()
+}
+
+func (m *portfolioModel) consumeProjectReadmeBack(msg tea.KeyMsg) bool {
+ if m.activeTab != tabProjects || !m.projectReadmeScroll {
+ return false
+ }
+
+ if msg.String() != "left" && !key.Matches(msg, m.keys.Back) {
+ return false
+ }
+
+ m.projectReadmeScroll = false
+ m.syncViewport()
+ return true
+}
+
+func (m *portfolioModel) consumeBlogBack(msg tea.KeyMsg) bool {
+ if m.activeTab != tabBlog || !m.blogOpen {
+ return false
+ }
+
+ if msg.String() != "left" && !key.Matches(msg, m.keys.Back) {
+ return false
+ }
+
+ m.blogOpen = false
+ m.syncViewport()
+ return true
+}
+
+func (m *portfolioModel) handleOpenKey() tea.Cmd {
+ switch m.activeTab {
+ case tabProjects:
+ if len(m.projects) == 0 {
+ return nil
+ }
+ m.projectReadmeScroll = !m.projectReadmeScroll
+ return nil
+ case tabBlog:
+ m.blogOpen = !m.blogOpen
+ m.syncViewport()
+ return m.maybeRenderOpenBlogCmd()
+ default:
+ return nil
+ }
+}
+
+func (m *portfolioModel) handleBackKey() tea.Cmd {
+ if m.activeTab == tabProjects && m.projectReadmeScroll {
+ m.projectReadmeScroll = false
+ m.syncViewport()
+ return nil
+ }
+
+ if m.activeTab == tabBlog && m.blogOpen {
+ m.blogOpen = false
+ m.syncViewport()
+ return nil
+ }
+
+ return tea.Quit
+}
+
+func (m portfolioModel) canPageScroll() bool {
+ return m.activeTab != tabProjects || m.projectReadmeScroll
+}
+
+func tabIndexFromKey(keyString string) (int, bool) {
+ if len(keyString) != 1 {
+ return 0, false
+ }
+
+ if keyString[0] < '1' || keyString[0] > '9' {
+ return 0, false
+ }
+
+ idx := int(keyString[0] - '1')
+ if idx < 0 || idx >= len(tabLabels) {
+ return 0, false
+ }
+
+ return idx, true
+}
+
+func (m *portfolioModel) moveSelection(delta int) tea.Cmd {
+ if m.activeTab == tabAbout || (m.activeTab == tabBlog && m.blogOpen) || (m.activeTab == tabProjects && m.projectReadmeScroll) {
+ scrollViewport(&m.viewport, delta)
+ return nil
+ }
+
+ switch m.activeTab {
+ case tabProjects:
+ if len(m.projects) == 0 {
+ scrollViewport(&m.viewport, delta)
+ return nil
+ }
+ m.projectIndex = wrapIndex(m.projectIndex+delta, len(m.projects))
+ m.syncViewport()
+ case tabExperience:
+ if len(m.experience) == 0 {
+ scrollViewport(&m.viewport, delta)
+ return nil
+ }
+ m.experienceIndex = wrapIndex(m.experienceIndex+delta, len(m.experience))
+ m.syncViewport()
+ case tabBlog:
+ if len(m.blogs) == 0 {
+ scrollViewport(&m.viewport, delta)
+ return nil
+ }
+ m.blogIndex = wrapIndex(m.blogIndex+delta, len(m.blogs))
+ m.syncViewport()
+ }
+
+ return nil
+}
+
+func scrollViewport(vp *viewport.Model, delta int) {
+ if delta > 0 {
+ _ = vp.LineDown(delta)
+ return
+ }
+ _ = vp.LineUp(-delta)
+}
diff --git a/internal/tui/view.go b/internal/tui/view.go
@@ -0,0 +1,287 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+func (m portfolioModel) View() string {
+ w := max(m.width, 1)
+ h := max(m.height, 1)
+ if w < minWindowWidth || h < minWindowHeight {
+ return m.renderMinSizeWarning(w, h)
+ }
+
+ if m.bootActive {
+ return m.renderBoot(w, h)
+ }
+
+ header := m.renderHeader(w)
+ footer := m.renderFooter(w)
+
+ bodyHeight := h - lipgloss.Height(header) - lipgloss.Height(footer)
+ bodyHeight = max(bodyHeight, 3)
+ body := m.renderBody(w, bodyHeight)
+ if m.help.ShowAll {
+ body = m.renderHelpBody(w, bodyHeight)
+ }
+ body = lipgloss.NewStyle().Width(w).Height(bodyHeight).Render(body)
+
+ parts := []string{header, body}
+ parts = append(parts, footer)
+
+ out := lipgloss.JoinVertical(lipgloss.Left, parts...)
+ return lipgloss.NewStyle().Width(w).MaxHeight(h).Render(out)
+}
+
+func (m portfolioModel) renderMinSizeWarning(width int, height int) string {
+ lines := []string{
+ titleStyle.Render("window too small"),
+ "",
+ rowStyle.Render(fmt.Sprintf("minimum: %dx%d", minWindowWidth, minWindowHeight)),
+ dimStyle.Render(fmt.Sprintf("current: %dx%d", width, height)),
+ "",
+ dimStyle.Render("resize terminal to continue"),
+ }
+
+ return lipgloss.Place(
+ width,
+ height,
+ lipgloss.Center,
+ lipgloss.Center,
+ strings.Join(lines, "\n"),
+ )
+}
+
+func (m portfolioModel) renderHelpBody(width int, height int) string {
+ shortcuts := []struct {
+ key string
+ desc string
+ }{
+ {key: "◀ ▶", desc: "navigate tabs"},
+ {key: "▲ ▼", desc: "switch list items"},
+ {key: "shift + ▲ ▼", desc: "scroll item details"},
+ {key: "tab", desc: "cycle tabs"},
+ {key: "1-4", desc: "switch tabs"},
+ {key: "enter", desc: "open item"},
+ {key: "?", desc: "toggle help"},
+ }
+
+ panelW := min(max(width-8, 42), 70)
+ panelW = min(panelW, max(width-2, 20))
+ panelH := min(max(len(shortcuts)+5, 10), max(height, 3))
+ innerW := max(panelW-4, 1)
+
+ title := titleStyle.Render("help")
+ closeHint := dimStyle.Render("press ? to close")
+ titleW := max(innerW-lipgloss.Width(closeHint)-1, 8)
+ header := lipgloss.JoinHorizontal(
+ lipgloss.Top,
+ lipgloss.NewStyle().Width(titleW).Render(title),
+ lipgloss.NewStyle().Width(max(innerW-titleW, 1)).Align(lipgloss.Right).Render(closeHint),
+ )
+
+ keyColW := 12
+ descColW := max(innerW-keyColW-1, 1)
+ helpKeyStyle := infoStyle.Bold(true)
+ rows := make([]string, 0, len(shortcuts)+2)
+ rows = append(rows, header, "")
+ for _, shortcut := range shortcuts {
+ keyText := helpKeyStyle.Render(padRunesRight(fitRunes(shortcut.key, keyColW), keyColW))
+ descText := rowStyle.Render(fitRunes(shortcut.desc, descColW))
+ rows = append(rows, keyText+" "+descText)
+ }
+
+ panel := renderPanelWithStyle(panelActiveStyle, panelW, panelH, strings.Join(rows, "\n"))
+ return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, panel)
+}
+
+func (m portfolioModel) renderBoot(width int, height int) string {
+ title := m.bootTitleFrame
+ if title == "" {
+ title = m.bootTitle
+ }
+
+ subtitle := m.bootSubtitleFrame
+ if subtitle == "" {
+ subtitle = m.bootSubtitle
+ }
+
+ titleStyle := bootTitleStyle
+ subtitleStyle := bootSubtitleStyle
+ if m.bootFadeProgress >= 0.66 {
+ titleStyle = titleStyle.Foreground(lipgloss.Color("242"))
+ subtitleStyle = subtitleStyle.Foreground(lipgloss.Color("238"))
+ } else if m.bootFadeProgress >= 0.33 {
+ titleStyle = titleStyle.Foreground(lipgloss.Color("247"))
+ subtitleStyle = subtitleStyle.Foreground(lipgloss.Color("240"))
+ }
+
+ contentWidth := max(width-4, 1)
+ content := lipgloss.JoinVertical(
+ lipgloss.Center,
+ titleStyle.Width(contentWidth).Align(lipgloss.Center).Render(fitRunes(title, contentWidth)),
+ subtitleStyle.Width(contentWidth).Align(lipgloss.Center).Render(fitRunes(subtitle, contentWidth)),
+ )
+
+ return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)
+}
+
+func (m portfolioModel) renderHeader(width int) string {
+ parts := make([]string, 0, len(tabLabels))
+ for i, label := range tabLabels {
+ t := fmt.Sprintf("[%d] %s", i+1, label)
+ if i == m.activeTab {
+ t = tabActiveStyle.Render(t)
+ } else {
+ t = dimStyle.Render(t)
+ }
+ parts = append(parts, t)
+ }
+
+ left := strings.Join(parts, " ")
+ right := ""
+ if width >= 96 {
+ right = dimStyle.Render("▲ ▼ ◀ ▶ navigate · tab cycle · ? help")
+ }
+
+ line := lipgloss.NewStyle().MaxWidth(width - 2).Render(left)
+ if right != "" {
+ leftW := max(width-lipgloss.Width(right)-4, 10)
+ rightW := max(width-leftW-2, 0)
+ line = lipgloss.JoinHorizontal(
+ lipgloss.Top,
+ lipgloss.NewStyle().Width(leftW).MaxWidth(leftW).Render(left),
+ lipgloss.NewStyle().Width(rightW).MaxWidth(rightW).Align(lipgloss.Right).Render(right),
+ )
+ }
+
+ return headerStyle.Width(width).Render(line)
+}
+
+func (m portfolioModel) renderBody(width int, height int) string {
+ bodyW := max(width, 20)
+ switch m.activeTab {
+ case tabAbout:
+ return m.renderAboutBody(bodyW, height)
+ case tabProjects:
+ return m.renderProjectsBody(bodyW, height)
+ default:
+ return renderPanelWithStyle(panelActiveStyle, bodyW, height, m.viewport.View())
+ }
+}
+
+func (m portfolioModel) renderAboutBody(bodyW int, height int) string {
+ if bodyW < 72 {
+ return renderPanelWithStyle(panelActiveStyle, bodyW, height, m.viewport.View())
+ }
+
+ leftW := min(max(bodyW/3, 26), 36)
+ rightW := max(bodyW-leftW, 12)
+ ascii := m.aboutASCIIFor(leftW - 4)
+ contactWidth := max(leftW-4, 14)
+
+ left := []string{}
+ if ascii != "" {
+ left = append(left, ascii, "")
+ }
+ left = append(left,
+ renderPrompt(m.about.Handle, "cat contact"),
+ renderKeyValueLine("email", "damian@prime8.dev", contactWidth, dimStyle, linkStyle),
+ renderKeyValueLine("gitlab", "git.prime8.dev/moncheeta", contactWidth, dimStyle, linkStyle),
+ renderKeyValueLine("linkedin", "linkedin.com/in/damian-myrda", contactWidth, dimStyle, linkStyle),
+ )
+
+ leftPane := renderPanelWithStyle(panelStyleWithoutRight(panelActiveStyle), leftW, height, strings.Join(left, "\n"))
+ rightPane := renderPanelWithStyle(panelStyleSharedLeft(panelActiveStyle), rightW, height, m.viewport.View())
+ return lipgloss.JoinHorizontal(lipgloss.Top, leftPane, rightPane)
+}
+
+func (m portfolioModel) renderProjectsBody(bodyW int, height int) string {
+ listPaneStyle := panelStyle
+ detailPaneStyle := panelStyle
+ if m.projectReadmeScroll {
+ detailPaneStyle = panelActiveStyle
+ } else {
+ listPaneStyle = panelActiveStyle
+ }
+
+ if bodyW < 72 {
+ stackTop := max(height/2, 3)
+ stackBottom := max(height-stackTop, 1)
+ listPane := renderPanelWithStyle(panelStyleWithoutBottom(listPaneStyle), bodyW, stackTop, m.projectListContent(bodyW-4, stackTop-2))
+ detailPane := renderPanelWithStyle(panelStyleSharedTop(detailPaneStyle), bodyW, stackBottom, m.viewport.View())
+ return lipgloss.JoinVertical(lipgloss.Left, listPane, detailPane)
+ }
+
+ listW := m.sidebarWidth(bodyW)
+ detailW := max(bodyW-listW, 12)
+ listPane := renderPanelWithStyle(panelStyleWithoutRight(listPaneStyle), listW, height, m.projectListContent(listW-4, height-2))
+ detailPane := renderPanelWithStyle(panelStyleSharedLeft(detailPaneStyle), detailW, height, m.viewport.View())
+ return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane)
+}
+
+func (m portfolioModel) renderFooter(width int) string {
+ left := footerTimeStyle.Render(m.now.Format("2006-01-02 15:04:05"))
+
+ right := ""
+ if m.wakaHours != "" {
+ right = footerWakaStyle.Render(m.wakaHours)
+ } else if !m.wakaLoaded {
+ right = footerDimStyle.Render("loading...")
+ }
+
+ contentW := max(width-2, 1)
+ leftCellStyle := lipgloss.NewStyle().Width(contentW).Background(colorFooterBg)
+ if right == "" {
+ return footerStyle.Width(width).Render(leftCellStyle.Render(left))
+ }
+
+ leftW := max(width-lipgloss.Width(right)-4, 10)
+ rightW := max(width-leftW-2, 0)
+ leftStyle := lipgloss.NewStyle().Width(leftW).MaxWidth(leftW).Background(colorFooterBg)
+ rightStyle := lipgloss.NewStyle().Width(rightW).MaxWidth(rightW).Align(lipgloss.Right).Background(colorFooterBg)
+ line := lipgloss.JoinHorizontal(
+ lipgloss.Top,
+ leftStyle.Render(left),
+ rightStyle.Render(right),
+ )
+ return footerStyle.Width(width).Render(line)
+}
+
+func (m portfolioModel) projectListContent(width int, maxLines int) string {
+ rows := []string{panelTitleStyle.Render("projects")}
+
+ for i, p := range m.projects {
+ name := fitRunes(p.Name, max(width-4, 8))
+ subtitle := fitRunes(p.Subtitle, max(width-4, 8))
+ tags := renderTagList(p.Tech)
+ if i == m.projectIndex && !m.projectReadmeScroll {
+ rows = append(rows, selectedRowStyle.Render("> "+fitRunes(name, max(width-2, 1))))
+ rows = append(rows, selectedRowStyle.Render(" "+fitRunes(subtitle, max(width-4, 1))))
+ if strings.TrimSpace(tags) != "" {
+ rows = append(rows, rowStyle.Render(" ")+tags)
+ }
+ } else {
+ rows = append(rows, rowStyle.Render(" "+fitRunes(name, max(width-2, 1))))
+ rows = append(rows, dimStyle.Render(" "+fitRunes(subtitle, max(width-4, 1))))
+ if strings.TrimSpace(tags) != "" {
+ rows = append(rows, dimStyle.Render(" ")+tags)
+ }
+ }
+ }
+ return clipLines(strings.Join(rows, "\n"), width, maxLines)
+}
+
+func renderPrompt(user, cmd string) string {
+ return promptUserStyle.Render(user) +
+ promptStyle.Render("@") +
+ promptHostStyle.Render("prime8") +
+ promptStyle.Render(":") +
+ promptPathStyle.Render("~") +
+ promptDollarStyle.Render("$") +
+ promptStyle.Render(" ") +
+ promptCmdStyle.Render(cmd)
+}
diff --git a/internal/tui/waka_footer.go b/internal/tui/waka_footer.go
@@ -0,0 +1,41 @@
+package tui
+
+import (
+ "git.prime8.dev/moncheeta/prime8dev/internal/external"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type wakaFooterMsg struct {
+ hours string
+ weekly *external.WeeklyStats
+ ok bool
+}
+
+type wakaFooterInitMsg struct{}
+
+func initWakaFooterCmd() tea.Cmd {
+ return func() tea.Msg {
+ return wakaFooterInitMsg{}
+ }
+}
+
+func (m *portfolioModel) maybeLoadWakaFooterCmd() tea.Cmd {
+ if m.wakaLoaded || m.wakaLoading || m.wakaTime == nil {
+ return nil
+ }
+
+ m.wakaLoading = true
+ return fetchWakaFooterCmd(m.wakaTime)
+}
+
+func fetchWakaFooterCmd(service *external.WakaTime) tea.Cmd {
+ return func() tea.Msg {
+ stats, err := service.FetchStats()
+ if err != nil || stats == nil {
+ return wakaFooterMsg{ok: false}
+ }
+
+ hasData := stats.AllTime.TotalText != "" || len(stats.Weekly.Days) > 0
+ return wakaFooterMsg{hours: stats.AllTime.TotalText, weekly: &stats.Weekly, ok: hasData}
+ }
+}
diff --git a/internal/utils/ascii.go b/internal/utils/ascii.go
@@ -0,0 +1,42 @@
+package utils
+
+import (
+ "strings"
+ "sync"
+
+ asciiart "github.com/romance-dev/ascii-art"
+ _ "github.com/romance-dev/ascii-art/fonts/doom"
+)
+
+var aboutNameASCIICache sync.Map
+
+func AboutNameASCII(name string) string {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return ""
+ }
+
+ if cached, ok := aboutNameASCIICache.Load(name); ok {
+ if ascii, ok := cached.(string); ok {
+ return ascii
+ }
+ }
+
+ rendered := name
+ func() {
+ defer func() {
+ if recover() != nil {
+ rendered = name
+ }
+ }()
+
+ fig := asciiart.NewFigure(name, "doom", false)
+ ascii := strings.TrimRight(fig.String(), "\n")
+ if ascii != "" {
+ rendered = ascii
+ }
+ }()
+
+ aboutNameASCIICache.Store(name, rendered)
+ return rendered
+}
diff --git a/internal/utils/markdown.go b/internal/utils/markdown.go
@@ -2,7 +2,6 @@ package utils
import (
"bytes"
- "html/template"
"strings"
"github.com/yuin/goldmark"
@@ -11,34 +10,28 @@ import (
"github.com/yuin/goldmark/renderer/html"
)
-var TemplateFuncs = template.FuncMap{
- "markdown": func(content string) template.HTML {
- html, err := MarkdownToHTML(content)
- if err != nil {
- return template.HTML(content)
- }
- return template.HTML(html)
- },
- "contains": strings.Contains,
-}
+var markdownRenderer = goldmark.New(
+ goldmark.WithExtensions(
+ extension.GFM,
+ extension.Typographer,
+ ),
+ goldmark.WithParserOptions(
+ parser.WithAutoHeadingID(),
+ ),
+ goldmark.WithRendererOptions(
+ html.WithHardWraps(),
+ html.WithXHTML(),
+ ),
+)
func MarkdownToHTML(markdown string) (string, error) {
- md := goldmark.New(
- goldmark.WithExtensions(
- extension.GFM,
- extension.Typographer,
- ),
- goldmark.WithParserOptions(
- parser.WithAutoHeadingID(),
- ),
- goldmark.WithRendererOptions(
- html.WithHardWraps(),
- html.WithXHTML(),
- ),
- )
+ markdown = strings.TrimSpace(markdown)
+ if markdown == "" {
+ return "", nil
+ }
var buf bytes.Buffer
- if err := md.Convert([]byte(RemoveFirstH1(markdown)), &buf); err != nil {
+ if err := markdownRenderer.Convert([]byte(markdown), &buf); err != nil {
return "", err
}
diff --git a/internal/view/ascii.go b/internal/view/ascii.go
@@ -1,34 +0,0 @@
-package view
-
-import (
- "strings"
- "sync"
-
- asciiart "github.com/romance-dev/ascii-art"
- _ "github.com/romance-dev/ascii-art/fonts/doom"
- _ "github.com/romance-dev/ascii-art/fonts/standard"
-)
-
-var (
- aboutASCIIOnce sync.Once
- aboutASCII string
-)
-
-func aboutNameASCII(name string) string {
- aboutASCIIOnce.Do(func() {
- defer func() {
- if recover() != nil {
- aboutASCII = name
- }
- }()
-
- fig := asciiart.NewFigure(name, "doom", false)
- aboutASCII = strings.TrimRight(fig.String(), "\n")
- })
-
- if aboutASCII == "" {
- return name
- }
-
- return aboutASCII
-}
diff --git a/internal/view/renderer.go b/internal/view/renderer.go
@@ -1,142 +0,0 @@
-package view
-
-import (
- "embed"
- "fmt"
- "html/template"
- "io/fs"
- "path"
- "strings"
-
- "git.prime8.dev/moncheeta/prime8dev/internal/config"
- "git.prime8.dev/moncheeta/prime8dev/internal/content"
- "git.prime8.dev/moncheeta/prime8dev/internal/external"
- "git.prime8.dev/moncheeta/prime8dev/internal/utils"
-)
-
-//go:embed templates/*.gohtml templates/partials/*.gohtml styles/*.css
-var templatesFS embed.FS
-
-type Data struct {
- Title string
- Description string
- ActiveTab string
- ContentTemplate string
- About config.About
- AboutNameASCII string
- Experience []config.Experience
- SelectedExperienceID string
- CurrentJob *config.Experience
- Projects []config.Project
- SelectedProjectID string
- Project *config.Project
- ProjectLinkLabel string
- ProjectReadme template.HTML
- Blogs []content.Blog
- SelectedBlogID string
- Blog *content.Blog
- WakaStats *external.WakaTimeStats
- Tabs []Tab
- SiteURL string
-}
-
-type Tab struct {
- ID string
- Label string
- Num int
-}
-
-type Renderer struct {
- tpl *template.Template
-}
-
-func New() (*Renderer, error) {
- inlineStyles, err := loadInlineStyles()
- if err != nil {
- return nil, err
- }
-
- funcs := template.FuncMap{
- "tabPath": utils.TabPath,
- "tabURL": utils.TabURL,
- "tagClass": utils.TagClass,
- "techMeta": utils.TechMeta,
- "formatMonthYear": utils.FormatMonthYear,
- "formatEndDate": utils.FormatEndDate,
- "formatDuration": utils.FormatDuration,
- "durationHasYear": utils.DurationHasYear,
- "timelineYear": utils.TimelineYear,
- "toHTML": toHTML,
- "inlineCSS": func(name string) template.CSS {
- if css, ok := inlineStyles[name]; ok {
- return css
- }
- return ""
- },
- }
-
- tpl, err := template.New("site").Funcs(funcs).ParseFS(templatesFS, "templates/*.gohtml", "templates/partials/*.gohtml")
- if err != nil {
- return nil, fmt.Errorf("parse templates: %w", err)
- }
-
- return &Renderer{tpl: tpl}, nil
-}
-
-func (r *Renderer) Render(name string, data Data) (string, error) {
- var b strings.Builder
- if err := r.tpl.ExecuteTemplate(&b, name, data); err != nil {
- return "", err
- }
- return b.String(), nil
-}
-
-func DefaultTabs() []Tab {
- return []Tab{
- {ID: "about", Label: "About", Num: 1},
- {ID: "projects", Label: "Projects", Num: 2},
- {ID: "experience", Label: "Experience", Num: 3},
- {ID: "blog", Label: "Blog", Num: 4},
- }
-}
-
-func BaseData(title, activeTab string, about config.About) Data {
- return Data{
- Title: title,
- Description: fmt.Sprintf("Portfolio and blog of %s", about.Name),
- ActiveTab: activeTab,
- ContentTemplate: activeTab,
- About: about,
- AboutNameASCII: aboutNameASCII(about.Name),
- Tabs: DefaultTabs(),
- SiteURL: config.SiteURL,
- }
-}
-
-func toHTML(s string) template.HTML {
- return template.HTML(s)
-}
-
-func loadInlineStyles() (map[string]template.CSS, error) {
- entries, err := fs.ReadDir(templatesFS, "styles")
- if err != nil {
- return nil, fmt.Errorf("read styles: %w", err)
- }
-
- styles := make(map[string]template.CSS, len(entries))
- for _, entry := range entries {
- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".css") {
- continue
- }
-
- stylePath := path.Join("styles", entry.Name())
- content, err := templatesFS.ReadFile(stylePath)
- if err != nil {
- return nil, fmt.Errorf("read style %s: %w", entry.Name(), err)
- }
-
- styles[entry.Name()] = template.CSS(string(content))
- }
-
- return styles, nil
-}
diff --git a/internal/view/scripts/modules/about.js b/internal/view/scripts/modules/about.js
@@ -1,93 +0,0 @@
-import { getWakaTimeData, subscribeWakaTimeRefresh } from "./wakatime.js";
-
-let unsubscribeAboutRefresh = null;
-
-export function initAbout() {
- const statsContainer = document.getElementById("activity-stats");
- const section = document.getElementById("activity-section");
- if (!statsContainer || !section) return;
-
- function hideActivity() {
- section.style.display = "none";
- }
-
- async function loadActivityData(data = null) {
- if (!section.isConnected) {
- if (unsubscribeAboutRefresh) {
- unsubscribeAboutRefresh();
- unsubscribeAboutRefresh = null;
- }
- return;
- }
-
- try {
- const stats = data || (await getWakaTimeData());
-
- if (!stats || !stats.Weekly || !Array.isArray(stats.Weekly.Days)) {
- hideActivity();
- return;
- }
-
- const totalText = stats.Weekly.TotalText;
- const dayBars = stats.Weekly.Days;
- if (!totalText || dayBars.length === 0) {
- hideActivity();
- return;
- }
-
- const days = dayBars.map((bar) => ({
- dayText: (bar.DayText || "").toLowerCase(),
- timeText: bar.TimeText || "",
- barWidthPercent:
- bar.BarWidthPercent && typeof bar.BarWidthPercent === "string"
- ? bar.BarWidthPercent.trim()
- : "0%",
- }));
-
- renderWeeklyStats(statsContainer, totalText, days);
- } catch {
- hideActivity();
- }
- }
-
- loadActivityData();
-
- if (unsubscribeAboutRefresh) {
- unsubscribeAboutRefresh();
- }
- unsubscribeAboutRefresh = subscribeWakaTimeRefresh((data) => {
- void loadActivityData(data);
- });
-}
-
-function renderWeeklyStats(statsContainer, totalText, days) {
- statsContainer.innerHTML = "";
-
- const width = 24;
- const lines = [`total: ${totalText}`, ""];
-
- days.forEach((day) => {
- const label = (day.dayText || "").slice(0, 3).padEnd(3, " ");
- const percent = clampPercent(day.barWidthPercent);
- const bar = asciiBar(percent, width);
- lines.push(`${label} |${bar}| ${day.timeText}`);
- });
-
- const pre = document.createElement("pre");
- pre.className = "weekly-ascii";
- pre.textContent = lines.join("\n");
- statsContainer.appendChild(pre);
-}
-
-function clampPercent(value) {
- const parsed = Number.parseFloat((value || "0").replace("%", ""));
- if (!Number.isFinite(parsed)) return 0;
- if (parsed < 0) return 0;
- if (parsed > 100) return 100;
- return parsed;
-}
-
-function asciiBar(percent, width) {
- const filled = Math.round((percent / 100) * width);
- return "#".repeat(filled) + "-".repeat(width - filled);
-}
diff --git a/internal/view/scripts/modules/blog.js b/internal/view/scripts/modules/blog.js
@@ -1,183 +0,0 @@
-import { isEditableTarget } from "./dom.js";
-import { clearAppTitleDetail, setAppTitleDetail, updateAppTitle } from "./header.js";
-
-const blogHandlers = {
- keydown: null,
- popstate: null,
-};
-
-export function initBlog() {
- const section = document.getElementById("blog");
- if (!section) return;
-
- const listView = section.querySelector("#blog-list-view");
- const postView = section.querySelector("#blog-post-view");
- const backBtn = section.querySelector(".blog-back-btn");
- const listItems = Array.from(section.querySelectorAll(".blog-list-item"));
- if (!listView || !postView || listItems.length === 0) return;
-
- let selectedIndex = 0;
- const hasPrefilledPost = Boolean(document.getElementById("blog-post-content")?.children.length);
- let postOpen = !postView.classList.contains("hidden") || window.location.pathname.startsWith("/blog/");
- window.isBlogPostOpen = () => postOpen;
-
- if (postOpen) {
- listView.classList.add("hidden");
- postView.classList.remove("hidden");
- } else {
- listView.classList.remove("hidden");
- postView.classList.add("hidden");
- }
-
- function pathBlogID() {
- return decodeURIComponent(window.location.pathname.match(/^\/blog\/([^/]+)$/)?.[1] || "");
- }
-
- function itemByBlogID(blogID) {
- if (!blogID) return null;
- return listItems.find((item) => item.dataset.id === blogID) || null;
- }
-
- function select(index) {
- selectedIndex = index;
- listItems.forEach((item, i) => item.classList.toggle("selected", i === index));
- listItems[index]?.scrollIntoView({ behavior: "smooth", block: "nearest" });
-
- const detail = listItems[index]?.querySelector(".blog-list-title")?.textContent?.trim() || "";
- setAppTitleDetail("blog", detail);
- updateAppTitle("blog");
- }
-
- function openPost(blogID, updateHistory = true) {
- postOpen = true;
- listView.classList.add("hidden");
- postView.classList.remove("hidden");
- postView.scrollTop = 0;
-
- if (blogID && updateHistory) {
- const path = `/blog/${encodeURIComponent(blogID)}`;
- if (window.location.pathname !== path) {
- history.replaceState({ tab: "blog" }, "", path);
- }
- }
-
- updateAppTitle("blog");
- }
-
- function closePost(updateHistory = true) {
- postOpen = false;
- listView.classList.remove("hidden");
- postView.classList.add("hidden");
- clearAppTitleDetail("blog");
-
- if (updateHistory && window.location.pathname !== "/blog") {
- history.replaceState({ tab: "blog" }, "", "/blog");
- }
-
- updateAppTitle("blog");
- }
-
- listItems.forEach((item, index) => {
- if (item.dataset.boundClick === "1") return;
- item.dataset.boundClick = "1";
-
- item.addEventListener("click", () => {
- select(index);
- openPost(item.dataset.id, true);
- });
- });
-
- if (backBtn && backBtn.dataset.boundClick !== "1") {
- backBtn.dataset.boundClick = "1";
- backBtn.addEventListener("click", () => closePost(true));
- }
-
- if (blogHandlers.keydown) {
- document.removeEventListener("keydown", blogHandlers.keydown);
- }
-
- blogHandlers.keydown = (e) => {
- if (!section.isConnected) return;
- if (window.isLightboxOpen?.()) return;
- if (isEditableTarget(e.target)) return;
-
- if (!postOpen) {
- switch (e.key) {
- case "ArrowUp":
- e.preventDefault();
- if (selectedIndex > 0) select(selectedIndex - 1);
- break;
- case "ArrowDown":
- e.preventDefault();
- if (selectedIndex < listItems.length - 1) select(selectedIndex + 1);
- break;
- case "ArrowRight":
- case "Enter":
- e.preventDefault();
- listItems[selectedIndex]?.click();
- break;
- default:
- break;
- }
- } else {
- switch (e.key) {
- case "ArrowLeft":
- case "Escape":
- e.preventDefault();
- closePost(true);
- break;
- case "ArrowUp":
- e.preventDefault();
- postView.scrollBy({ top: -80, behavior: "smooth" });
- break;
- case "ArrowDown":
- e.preventDefault();
- postView.scrollBy({ top: 80, behavior: "smooth" });
- break;
- default:
- break;
- }
- }
- };
-
- document.addEventListener("keydown", blogHandlers.keydown);
-
- if (blogHandlers.popstate) {
- window.removeEventListener("popstate", blogHandlers.popstate);
- }
-
- blogHandlers.popstate = () => {
- if (!section.isConnected) return;
-
- const currentID = pathBlogID();
- if (!currentID) {
- closePost(false);
- return;
- }
-
- const item = itemByBlogID(currentID);
- if (!item) return;
-
- const idx = Number(item.dataset.index || 0);
- select(idx);
- openPost(currentID, false);
-
- if (!document.getElementById("blog-post-content")?.children.length) {
- item.click();
- }
- };
-
- window.addEventListener("popstate", blogHandlers.popstate);
-
- const pathID = pathBlogID();
- const selectedFromPath = itemByBlogID(pathID);
- const initialItem = selectedFromPath || section.querySelector(".blog-list-item.selected") || listItems[0];
- if (!initialItem) return;
-
- const initialIndex = Number(initialItem.dataset.index || 0);
- select(initialIndex);
-
- if (postOpen && !hasPrefilledPost) {
- initialItem.click();
- }
-}
diff --git a/internal/view/scripts/modules/experience.js b/internal/view/scripts/modules/experience.js
@@ -1,121 +0,0 @@
-import { isEditableTarget } from "./dom.js";
-
-const experienceHandlers = {
- keydown: null,
- popstate: null,
-};
-
-export function initExperience() {
- const section = document.getElementById("experience");
- if (!section) return;
-
- const cards = Array.from(section.querySelectorAll(".exp-card"));
- const timelines = Array.from(section.querySelectorAll(".exp-timeline"));
- const rows = Array.from(section.querySelectorAll(".exp-row"));
- if (cards.length === 0) return;
-
- let selectedIndex = 0;
-
- function experienceIDAt(index) {
- return cards[index]?.dataset.id || rows[index]?.dataset.id || "";
- }
-
- function indexFromPath() {
- const id = decodeURIComponent(window.location.pathname.match(/^\/experience\/([^/]+)$/)?.[1] || "");
- if (!id) return -1;
- return cards.findIndex((card) => card.dataset.id === id);
- }
-
- function updateExperienceURL(index) {
- const id = experienceIDAt(index);
- if (!id) return;
-
- const path = `/experience/${encodeURIComponent(id)}`;
- if (window.location.pathname !== path) {
- history.replaceState({ tab: "experience" }, "", path);
- }
- }
-
- function select(index, updateURL = true) {
- selectedIndex = index;
- cards.forEach((card, i) => card.classList.toggle("selected", i === index));
- timelines.forEach((timeline, i) => timeline.classList.toggle("active", i === index));
-
- if (updateURL) {
- updateExperienceURL(index);
- }
- }
-
- cards.forEach((card, i) => {
- if (card.dataset.boundHover !== "1") {
- card.dataset.boundHover = "1";
- card.addEventListener("mouseenter", () => select(i));
- }
- });
-
- timelines.forEach((timeline, i) => {
- if (timeline.dataset.boundHover !== "1") {
- timeline.dataset.boundHover = "1";
- timeline.addEventListener("mouseenter", () => select(i));
- }
- });
-
- if (experienceHandlers.keydown) {
- document.removeEventListener("keydown", experienceHandlers.keydown);
- }
-
- experienceHandlers.keydown = (e) => {
- if (!section.isConnected || window.isLightboxOpen?.()) return;
- if (isEditableTarget(e.target)) return;
-
- switch (e.key) {
- case "ArrowUp":
- e.preventDefault();
- if (selectedIndex > 0) select(selectedIndex - 1);
- break;
- case "ArrowDown":
- e.preventDefault();
- if (selectedIndex < cards.length - 1) select(selectedIndex + 1);
- break;
- default:
- break;
- }
- };
-
- document.addEventListener("keydown", experienceHandlers.keydown);
-
- if (experienceHandlers.popstate) {
- window.removeEventListener("popstate", experienceHandlers.popstate);
- }
-
- experienceHandlers.popstate = () => {
- if (!section.isConnected) return;
- const index = indexFromPath();
- if (index >= 0) {
- select(index, false);
- }
- };
-
- window.addEventListener("popstate", experienceHandlers.popstate);
-
- section.querySelectorAll(".exp-image-hero, .exp-image-thumb").forEach((img) => {
- if (img.dataset.boundClick === "1") return;
- img.dataset.boundClick = "1";
-
- img.addEventListener("click", () => {
- const container = img.closest(".exp-card-images");
- const allImages = container ? Array.from(container.querySelectorAll("img")).map((i) => i.src) : [];
- const startIndex = allImages.indexOf(img.src);
- window.openLightbox?.(allImages, startIndex);
- });
- });
-
- const selectedCard = section.querySelector(".exp-card.selected") || cards[0];
- const initialIndex = Math.max(cards.indexOf(selectedCard), 0);
- const pathIndex = indexFromPath();
- if (pathIndex >= 0) {
- select(pathIndex, false);
- } else {
- select(initialIndex);
- }
-}
diff --git a/internal/view/scripts/modules/help.js b/internal/view/scripts/modules/help.js
@@ -1,80 +0,0 @@
-import { isEditableTarget } from "./dom.js";
-
-const SHORTCUTS = [
- { key: "◀ ▶", description: "navigate tabs" },
- { key: "▲ ▼", description: "scroll content" },
- { key: "tab", description: "cycle tabs" },
- { key: "1-4", description: "switch tabs" },
- { key: "enter", description: "open item" },
- { key: "?", description: "toggle help" },
-];
-
-const helpHandlers = {
- keydown: null,
-};
-
-export function initHelp() {
- const overlay = createHelpOverlay();
-
- if (helpHandlers.keydown) {
- document.removeEventListener("keydown", helpHandlers.keydown);
- }
-
- helpHandlers.keydown = (e) => {
- if (isEditableTarget(e.target)) return;
-
- switch (e.key) {
- case "?":
- e.preventDefault();
- overlay.classList.toggle("visible");
- break;
- case "Escape":
- if (overlay.classList.contains("visible")) {
- e.preventDefault();
- overlay.classList.remove("visible");
- }
- break;
- default:
- break;
- }
- };
-
- document.addEventListener("keydown", helpHandlers.keydown);
-}
-
-function createHelpOverlay() {
- const existing = document.getElementById("help-overlay");
- if (existing) return existing;
-
- const overlay = document.createElement("div");
- overlay.id = "help-overlay";
- overlay.setAttribute("aria-hidden", "true");
-
- overlay.innerHTML = `
- <div class="help-modal" role="dialog" aria-modal="true" aria-label="keyboard shortcuts">
- <div class="help-header">
- <span class="help-title">help</span>
- <span class="help-close">press ? to close</span>
- </div>
- <div class="help-content">
- ${SHORTCUTS.map(
- (shortcut) => `
- <div class="help-row">
- <span class="help-key">${shortcut.key}</span>
- <span class="help-desc">${shortcut.description}</span>
- </div>
- `,
- ).join("")}
- </div>
- </div>
- `;
-
- overlay.addEventListener("click", (e) => {
- if (e.target === overlay) {
- overlay.classList.remove("visible");
- }
- });
-
- document.body.appendChild(overlay);
- return overlay;
-}
diff --git a/internal/view/scripts/modules/layout.js b/internal/view/scripts/modules/layout.js
@@ -1,144 +0,0 @@
-import {
- getWakaTimeData,
- startWakaTimeRefreshTimer,
- subscribeWakaTimeRefresh,
-} from "./wakatime.js";
-
-const BOOT_CHARS = "▓▒░█▄▀■□▪▫●○◆◇";
-const SCRAMBLE_DURATION = 400;
-const SCRAMBLE_INTERVAL = 30;
-const HOLD_DURATION = 450;
-const FADE_DURATION = 300;
-
-let bootPlayed = false;
-let statusTimer = 0;
-let unsubscribeWakaFooter = null;
-
-export async function initLayout() {
- await playBootAnimation();
-
- startStatusClock();
-
- await loadWakaFooter();
- if (unsubscribeWakaFooter) {
- unsubscribeWakaFooter();
- }
- unsubscribeWakaFooter = subscribeWakaTimeRefresh((data) => {
- void loadWakaFooter(data);
- });
- startWakaTimeRefreshTimer();
-}
-
-function sleep(ms) {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-function scrambleText(length) {
- return Array.from(
- { length },
- () => BOOT_CHARS[Math.floor(Math.random() * BOOT_CHARS.length)],
- ).join("");
-}
-
-async function scrambleReveal(element, target, duration) {
- const steps = Math.floor(duration / SCRAMBLE_INTERVAL);
- const length = target.length;
-
- for (let i = 0; i <= steps; i++) {
- const progress = i / steps;
- const revealedCount = Math.floor(progress * length);
- const revealed = target.slice(0, revealedCount);
- const scrambled = scrambleText(length - revealedCount);
- element.textContent = revealed + scrambled;
- await sleep(SCRAMBLE_INTERVAL);
- }
-
- element.textContent = target;
-}
-
-function createBootOverlay(title, subtitle) {
- const overlay = document.createElement("div");
- overlay.id = "boot-overlay";
- overlay.innerHTML =
- '<div class="boot-terminal"><div class="boot-text"><span class="boot-title"></span><span class="boot-subtitle"></span></div></div>';
-
- const bootTitle = overlay.querySelector(".boot-title");
- const bootSubtitle = overlay.querySelector(".boot-subtitle");
- if (bootTitle) bootTitle.textContent = scrambleText(title.length);
- if (bootSubtitle) bootSubtitle.textContent = scrambleText(subtitle.length);
-
- document.body.appendChild(overlay);
- return overlay;
-}
-
-async function playBootAnimation() {
- const app = document.getElementById("app");
- if (!app) return;
-
- if (bootPlayed || app.classList.contains("ready")) {
- bootPlayed = true;
- app.classList.add("ready");
- return;
- }
-
- const title = app.dataset.bootTitle || "prime8.dev";
- const subtitle = app.dataset.bootSubtitle || "www.prime8.dev";
-
- const overlay = createBootOverlay(title, subtitle);
- const bootTitle = overlay.querySelector(".boot-title");
- const bootSubtitle = overlay.querySelector(".boot-subtitle");
-
- await sleep(100);
- if (bootTitle) await scrambleReveal(bootTitle, title, SCRAMBLE_DURATION);
- if (bootSubtitle)
- await scrambleReveal(bootSubtitle, subtitle, SCRAMBLE_DURATION);
-
- await sleep(HOLD_DURATION);
- overlay.classList.add("fade-out");
- await sleep(FADE_DURATION);
-
- overlay.remove();
- app.classList.add("ready", "boot-reveal");
- bootPlayed = true;
-}
-
-function updateStatusTime() {
- const el = document.getElementById("status-time");
- if (!el) return;
-
- const now = new Date();
- const time = now.toLocaleTimeString();
- const date = now.toLocaleDateString();
- el.textContent = `${date} ${time}`;
-}
-
-function startStatusClock() {
- if (statusTimer) {
- clearTimeout(statusTimer);
- }
-
- const tick = () => {
- updateStatusTime();
- const delay = 1000 - (Date.now() % 1000) || 1000;
- statusTimer = window.setTimeout(tick, delay);
- };
-
- tick();
-}
-
-async function loadWakaFooter(data = null) {
- const link = document.getElementById("wakatime-link");
- if (!link) return;
-
- try {
- const stats = data || (await getWakaTimeData());
- if (!stats || !stats.AllTime || !stats.AllTime.TotalText) {
- link.style.display = "none";
- return;
- }
-
- link.textContent = stats.AllTime.TotalText;
- } catch {
- link.style.display = "none";
- }
-}
diff --git a/internal/view/scripts/modules/projects.js b/internal/view/scripts/modules/projects.js
@@ -1,138 +0,0 @@
-import { isEditableTarget } from "./dom.js";
-import { setAppTitleDetail, updateAppTitle } from "./header.js";
-
-const projectsHandlers = {
- keydown: null,
-};
-
-export function initProjects() {
- const section = document.getElementById("projects");
- if (!section) return;
-
- const items = Array.from(section.querySelectorAll(".list-item"));
- if (items.length === 0) return;
-
- const detailView = section.querySelector(".detail-view");
-
- let selectedIndex = 0;
-
- function readmeScroller() {
- if (!detailView) return null;
-
- const readme = detailView.querySelector(".readme-content");
- if (readme && readme.scrollHeight > readme.clientHeight) {
- return readme;
- }
-
- if (detailView.scrollHeight > detailView.clientHeight) {
- return detailView;
- }
-
- return null;
- }
-
- function setSelected(selectedItem) {
- items.forEach((item) => item.classList.toggle("selected", item === selectedItem));
- selectedIndex = items.indexOf(selectedItem);
- if (selectedIndex < 0) selectedIndex = 0;
-
- selectedItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
-
- const detail = selectedItem.querySelector(".list-title")?.textContent?.trim() || "";
- setAppTitleDetail("projects", detail);
- updateAppTitle("projects");
- }
-
- function updateProjectURL(projectID) {
- if (!projectID) return;
- const path = `/projects/${encodeURIComponent(projectID)}`;
- if (window.location.pathname !== path) {
- history.replaceState({ tab: "projects" }, "", path);
- updateAppTitle("projects");
- }
- }
-
- function loadDetailForItem(item) {
- const url = item.getAttribute("hx-get");
- if (!url) return;
-
- updateProjectURL(item.dataset.id);
-
- if (window.htmx && typeof window.htmx.ajax === "function") {
- window.htmx.ajax("GET", url, "#project-detail");
- return;
- }
-
- item.click();
- }
-
- function selectByIndex(index, loadDetail) {
- if (index < 0 || index >= items.length) return;
- setSelected(items[index]);
- if (loadDetail) loadDetailForItem(items[index]);
- }
-
- function openExternalForIndex(index) {
- if (index < 0 || index >= items.length) return;
- const url = items[index]?.dataset.url || "";
- if (!url) return;
- window.open(url, "_blank", "noopener,noreferrer");
- }
-
- items.forEach((item, index) => {
- if (item.dataset.boundClick === "1") return;
- item.dataset.boundClick = "1";
- item.addEventListener("click", () => {
- selectedIndex = index;
- setSelected(item);
- updateProjectURL(item.dataset.id);
- });
- });
-
- if (projectsHandlers.keydown) {
- document.removeEventListener("keydown", projectsHandlers.keydown);
- }
-
- projectsHandlers.keydown = (e) => {
- if (!section.isConnected) return;
- if (isEditableTarget(e.target)) return;
- if (window.isLightboxOpen?.()) return;
-
- if ((e.key === "ArrowUp" || e.key === "ArrowDown") && detailView?.matches(":hover")) {
- const scroller = readmeScroller();
- if (scroller) {
- e.preventDefault();
- scroller.scrollBy({ top: e.key === "ArrowUp" ? -80 : 80, behavior: "smooth" });
- return;
- }
- }
-
- switch (e.key) {
- case "ArrowUp":
- e.preventDefault();
- selectByIndex(selectedIndex - 1, true);
- break;
- case "ArrowDown":
- e.preventDefault();
- selectByIndex(selectedIndex + 1, true);
- break;
- case "Enter":
- e.preventDefault();
- openExternalForIndex(selectedIndex);
- break;
- default:
- break;
- }
- };
-
- document.addEventListener("keydown", projectsHandlers.keydown);
-
- const initialItem = section.querySelector(".list-item.selected") || items[0];
- setSelected(initialItem);
-
- const detailContainer = document.getElementById("project-detail");
- const needsInitialFetch = detailContainer?.querySelector("[data-project-placeholder='true']");
- if (needsInitialFetch) {
- setTimeout(() => loadDetailForItem(initialItem), 0);
- }
-}
diff --git a/internal/view/styles/about.css b/internal/view/styles/about.css
@@ -1,276 +0,0 @@
-.about-layout {
- display: flex;
- width: 100%;
- height: 100%;
-}
-
-.about-left {
- width: 320px;
- flex-shrink: 0;
- border-right: 1px solid var(--border);
- display: flex;
- flex-direction: column;
- padding: 16px;
- gap: 16px;
- overflow-y: auto;
-}
-
-.about-right {
- flex: 1;
- padding: 24px 32px;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- justify-content: flex-start;
- gap: 14px;
-}
-
-.profile-image {
- width: 100%;
- max-width: 280px;
- height: auto;
- border-radius: 4px;
- border: 1px solid var(--border);
-}
-
-.contact-box {
- border: 1px solid var(--border);
- padding: 12px;
- max-width: 280px;
-}
-
-.box-title {
- color: var(--accent);
- font-size: var(--fs-12);
- margin-bottom: 4px;
- text-align: left;
-}
-
-.shell-prompt {
- color: var(--fg-dim);
- font-size: var(--fs-14);
- margin-bottom: 3px;
-}
-
-.shell-user {
- color: var(--green);
-}
-
-.shell-dollar {
- color: var(--green);
-}
-
-.shell-host {
- color: var(--accent);
-}
-
-.shell-path {
- color: var(--cyan);
-}
-
-.shell-cmd {
- color: var(--fg);
-}
-
-.name-ascii {
- margin: 0;
- color: var(--fg);
- font-size: clamp(6px, 0.7vw + 4px, 12px);
- line-height: 1;
- white-space: pre;
- overflow: clip;
-}
-
-@media (max-width: 1024px) {
- .name-ascii {
- font-size: clamp(6px, 0.5vw + 4px, var(--fs-9));
- line-height: 1;
- }
-}
-
-.contact-item {
- display: flex;
- margin-bottom: 4px;
- font-size: var(--fs-13);
- min-width: 0;
-}
-
-.contact-item .label {
- color: var(--fg-dim);
- width: 70px;
- flex-shrink: 0;
-}
-
-.contact-item a {
- color: var(--light-blue);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- direction: rtl;
- text-align: left;
-}
-
-.contact-item a:hover {
- opacity: 0.8;
-}
-
-.section {
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.section .dim {
- color: var(--fg-dim);
- font-size: inherit;
-}
-
-.section .bio {
- color: var(--fg);
- font-size: var(--fs-14);
- font-style: normal;
- line-height: 1.6;
- margin: 8px 0 0;
-}
-
-.info-columns {
- display: flex;
- gap: 30px;
-}
-
-.info-column {
- flex: 1;
-}
-
-.info-row {
- display: flex;
- font-size: var(--fs-14);
- margin-bottom: 2px;
-}
-
-.info-row .label {
- color: var(--fg-dim);
- width: 100px;
- flex-shrink: 0;
-}
-
-.tags {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-
-.weekly-stats {
- background: var(--bg-alt);
- border: 1px solid var(--border);
- padding: 12px;
-}
-
-.weekly-ascii {
- margin: 0;
- color: var(--fg);
- font-size: var(--fs-12);
- line-height: 1.4;
- white-space: pre;
- overflow-x: auto;
-}
-
-.weekly-stats .loading-text {
- color: var(--fg-dim);
- font-style: italic;
- text-align: center;
- padding: 20px 0;
-}
-
-.weekly-total {
- font-size: var(--fs-16);
- font-weight: 500;
- color: var(--accent);
- margin-bottom: 12px;
-}
-
-.weekly-days {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.weekly-day-row {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: var(--fs-12);
-}
-
-.weekly-day-name {
- width: 32px;
- flex-shrink: 0;
- color: var(--fg-dim);
-}
-
-.weekly-day-bar-container {
- flex: 1;
- height: 8px;
- background: var(--bg);
- border-radius: 2px;
- overflow: hidden;
-}
-
-.weekly-day-bar {
- height: 100%;
- background: var(--accent);
- border-radius: 2px;
-}
-
-.weekly-day-time {
- min-width: 50px;
- flex-shrink: 0;
- text-align: right;
- color: var(--fg-dim);
- white-space: nowrap;
-}
-
-@media (max-width: 768px) {
- .about-layout {
- flex-direction: column;
- }
-
- .about-left {
- flex-direction: row;
- width: 100%;
- border-right: none;
- border-bottom: 1px solid var(--border);
- }
-
- .profile-image {
- width: 120px;
- height: 120px;
- object-fit: cover;
- }
-
- .contact-box {
- flex: 1;
- max-width: none;
- min-width: 0;
- }
-
- .about-right {
- padding: 16px;
- display: block;
- }
-
- .section {
- margin-bottom: 16px;
- }
-
- .name-ascii {
- font-size: clamp(5px, 0.45vw + 3.2px, var(--fs-8));
- line-height: 1;
- }
-
- .info-columns {
- flex-direction: column;
- gap: 12px;
- }
-}
diff --git a/internal/view/templates/layout.gohtml b/internal/view/templates/layout.gohtml
@@ -1,63 +0,0 @@
-{{define "layout"}}
-<!doctype html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>{{.Title}} | {{.About.Name}}</title>
- <meta name="description" content="{{.Description}}">
- <link rel="canonical" href="{{tabURL .ActiveTab}}">
- <link rel="alternate" hreflang="en" href="{{tabURL .ActiveTab}}">
- <link rel="alternate" hreflang="x-default" href="{{tabURL .ActiveTab}}">
- <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin="anonymous">
- <link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
- <link rel="preload" href="https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.0.18/files/jetbrains-mono-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin="anonymous">
- <meta property="og:type" content="website">
- <meta property="og:url" content="{{tabURL .ActiveTab}}">
- <meta property="og:title" content="{{.Title}} | {{.About.Name}}">
- <meta property="og:description" content="{{.Description}}">
- <meta property="og:image" content="https://www.prime8.dev/static/me.jpg">
- <meta property="og:image:width" content="1200">
- <meta property="og:image:height" content="630">
- <meta property="og:site_name" content="{{.About.Name}}">
- <meta property="og:locale" content="en_US">
- <meta name="twitter:card" content="summary_large_image">
- <meta name="twitter:url" content="{{tabURL .ActiveTab}}">
- <meta name="twitter:title" content="{{.Title}} | {{.About.Name}}">
- <meta name="twitter:description" content="{{.Description}}">
- <meta name="twitter:image" content="https://www.prime8.dev/static/me.jpg">
- <meta name="author" content="{{.About.Name}}">
- <meta name="robots" content="index, follow">
- <meta name="theme-color" content="#0d0d0d">
- <link rel="icon" type="image/png" href="/static/favicon.png">
- <link rel="apple-touch-icon" href="/static/favicon.png">
- <link rel="manifest" href="/static/site.webmanifest">
- <script defer src="https://metrics.prime8.dev/script.js" data-website-id="d3032378-7959-4411-92ec-f67e20399fe4"></script>
- <script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
- <style>{{inlineCSS "layout.css"}}</style>
-</head>
-<body>
- <div id="app" data-boot-title="{{.About.Name}}" data-boot-subtitle="www.prime8.dev">
- {{template "header" .}}
- <main id="content">
- {{if eq .ContentTemplate "about"}}
- {{template "about" .}}
- {{else if eq .ContentTemplate "projects"}}
- {{template "projects" .}}
- {{else if eq .ContentTemplate "experience"}}
- {{template "experience" .}}
- {{else if eq .ContentTemplate "blog"}}
- {{template "blog" .}}
- {{end}}
- </main>
- {{template "footer" .}}
- </div>
-
- {{template "lightbox" .}}
-
- <script src="/static/app.js" defer></script>
- <script type="application/ld+json">{"@context":"https://schema.org","@type":"Person","name":"{{.About.Name}}"}</script>
- <script type="application/ld+json">{"@context":"https://schema.org","@type":"WebSite","name":"{{.About.Name}}","url":"https://www.prime8.dev"}</script>
-</body>
-</html>
-{{end}}
diff --git a/internal/view/templates/partials/about.gohtml b/internal/view/templates/partials/about.gohtml
@@ -1,70 +0,0 @@
-{{define "about"}}
-<section id="about" class="page active">
- <div class="about-layout">
- <div class="about-left">
- <img class="profile-image" src="/static/me.jpg" alt="damian myrda - software engineer and high school senior from chicago" loading="eager">
- <div class="contact-box">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">cat contact</span></div>
- <div class="contact-item"><span class="label">email</span><a href="mailto:damian@prime8.dev">damian@prime8.dev</a></div>
- <div class="contact-item"><span class="label">gitlab</span><a href="https://git.prime8.dev/moncheeta" target="_blank" rel="noopener noreferrer me">git.prime8.dev/moncheeta</a></div>
- <div class="contact-item"><span class="label">linkedin</span><a href="https://linkedin.com/in/damian-myrda" target="_blank" rel="noopener noreferrer me">linkedin.com/in/damian-myrda</a></div>
- </div>
- </div>
-
- <div class="about-right">
- <div class="section">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">whoami</span></div>
- <pre class="name-ascii" aria-label="{{.About.Name}}">{{.AboutNameASCII}}</pre>
- <p class="bio">{{.About.Bio}}</p>
- </div>
-
- <div class="section">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">cat info</span></div>
- <div class="info-columns">
- <div class="info-column">
- <div class="info-row"><span class="label">location:</span>{{.About.Location}}</div>
- <div class="info-row"><span class="label">school:</span>{{.About.School}}</div>
- <div class="info-row"><span class="label">graduating:</span>{{.About.Graduating}}</div>
- </div>
- {{with .CurrentJob}}
- <div class="info-column">
- <div class="info-row"><span class="label">company:</span><a href="/experience/{{.ID}}" hx-get="/experience/{{.ID}}" hx-target="#content" hx-push-url="true">{{.Company}}</a></div>
- <div class="info-row"><span class="label">position:</span>{{.Role}}</div>
- </div>
- {{end}}
- </div>
- </div>
-
- <div class="section">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">ls interests/</span></div>
- <div class="tags">
- {{range .About.Interests}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}}
- </div>
- </div>
-
- <div class="section">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">ls tools/</span></div>
- <div class="tags">
- {{range .About.Tools}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}}
- </div>
- </div>
-
- <div class="section">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">ls languages/</span></div>
- <div class="tags">
- {{range .About.Languages}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}}
- </div>
- </div>
-
- <div class="section" id="activity-section">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">wakatime --week</span></div>
- <div class="weekly-stats" id="activity-stats">
- <div class="loading-text">loading stats...</div>
- </div>
- </div>
- </div>
- </div>
-</section>
-
-<style>{{inlineCSS "about.css"}}</style>
-{{end}}
diff --git a/internal/view/templates/partials/blog.gohtml b/internal/view/templates/partials/blog.gohtml
@@ -1,46 +0,0 @@
-{{define "blog"}}
-<section id="blog" class="page">
- {{if eq (len .Blogs) 0}}
- <div class="blog-empty">
- <p class="color-dim">~/blog is empty</p>
- <p class="color-dim">check back soon</p>
- </div>
- {{else}}
- <div id="blog-list-view" class="blog-list-view{{if .Blog}} hidden{{end}}">
- <div class="blog-fs-shell">
- <div class="blog-fs-header">~/blog $ ls -l</div>
- <div class="blog-list-columns">
- <span class="col-mode">mode</span>
- <span>date</span>
- <span>name</span>
- <span class="col-desc">desc</span>
- <span class="col-arrow"></span>
- </div>
- <div class="blog-list-container">
- {{range $i, $blog := .Blogs}}
- <div class="blog-list-item{{if $.SelectedBlogID}}{{if eq $blog.ID $.SelectedBlogID}} selected{{end}}{{else}}{{if eq $i 0}} selected{{end}}{{end}}" data-index="{{$i}}" data-id="{{$blog.ID}}" hx-get="/api/blog/{{$blog.ID}}" hx-target="#blog-post-content" hx-swap="innerHTML">
- <span class="blog-list-mode">-rw-r--r--</span>
- <span class="blog-list-date">{{$blog.Date.Format "2006-01-02"}}</span>
- <span class="blog-list-name">
- <span class="blog-list-file">{{$blog.ID}}.md</span>
- <span class="blog-list-title">{{$blog.Title}}</span>
- </span>
- <span class="blog-list-desc" title="{{$blog.Description}}">{{$blog.Description}}</span>
- <span class="blog-list-arrow">-></span>
- </div>
- {{end}}
- </div>
- </div>
- </div>
-
- <div id="blog-post-view" class="blog-post-view{{if not .Blog}} hidden{{end}}">
- <div class="blog-post-container">
- <button class="blog-back-btn">cd ..</button>
- <div id="blog-post-content">{{if .Blog}}{{template "blog_detail" .}}{{end}}</div>
- </div>
- </div>
- {{end}}
-</section>
-
-<style>{{inlineCSS "blog.css"}}</style>
-{{end}}
diff --git a/internal/view/templates/partials/blog_detail.gohtml b/internal/view/templates/partials/blog_detail.gohtml
@@ -1,28 +0,0 @@
-{{define "blog_detail"}}
-{{if .Blog}}
-<article class="blog-article" data-id="{{.Blog.ID}}" data-blog-id="{{.Blog.ID}}">
- <header class="blog-article-header">
- <div class="blog-article-header-top">
- <h1 class="blog-article-title">~/blog/{{.Blog.ID}}.md</h1>
- <button class="blog-share-btn blog-share-btn-desktop" data-blog-id="{{.Blog.ID}}" data-blog-title="{{.Blog.ID}}.md">[Share]</button>
- </div>
- <div class="blog-article-meta">
- <time class="blog-article-date" datetime="{{.Blog.Date.Format "2006-01-02"}}">{{.Blog.Date.Format "2006-01-02"}}</time>
- <button class="blog-share-btn blog-share-btn-mobile" data-blog-id="{{.Blog.ID}}" data-blog-title="{{.Blog.ID}}.md">[Share]</button>
- </div>
- </header>
-
- <div class="blog-article-content" data-blog-id="{{.Blog.ID}}">
- {{if .Blog.URL}}
- <a class="external-link" href="{{.Blog.URL}}" target="_blank" rel="noopener noreferrer">Read on external site -></a>
- {{else if .Blog.Content}}
- {{toHTML .Blog.Content}}
- {{else}}
- <p class="color-dim">Content not available</p>
- {{end}}
- </div>
-</article>
-
-<style>{{inlineCSS "blog_detail.css"}}</style>
-{{end}}
-{{end}}
diff --git a/internal/view/templates/partials/header.gohtml b/internal/view/templates/partials/header.gohtml
@@ -1,21 +0,0 @@
-{{define "header"}}
-<header id="tabs" data-active-tab="{{.ActiveTab}}" data-tabs="about,projects,experience,blog">
- <nav class="tab-nav" aria-label="main navigation">
- {{range .Tabs}}
- <a href="{{tabPath .ID}}" class="tab{{if eq .ID $.ActiveTab}} active{{end}}" data-tab="{{.ID}}" data-label="{{.ID}}" hx-get="{{tabPath .ID}}" hx-target="#content" hx-push-url="true">
- <span class="tab-num">[{{.Num}}] </span>{{.ID}}
- </a>
- {{end}}
- </nav>
- <div class="tab-spacer"></div>
- <div class="status" id="header-status">
- <span class="dpad">
- <span class="dpad-row"><span class="dpad-key">▲</span></span>
- <span class="dpad-row"><span class="dpad-key">◀</span><span class="dpad-key">▼</span><span class="dpad-key">▶</span></span>
- </span>
- <span class="navigate"> navigate · tab cycle · ? help</span>
- </div>
-</header>
-
-<style>{{inlineCSS "header.css"}}</style>
-{{end}}
diff --git a/internal/view/templates/partials/projects.gohtml b/internal/view/templates/partials/projects.gohtml
@@ -1,28 +0,0 @@
-{{define "projects"}}
-<section id="projects" class="page">
- <aside class="list-view" aria-label="Project list">
- {{range $i, $p := .Projects}}
- <div class="list-item{{if $.SelectedProjectID}}{{if eq $p.ID $.SelectedProjectID}} selected{{end}}{{else}}{{if eq $i 0}} selected{{end}}{{end}}" data-index="{{$i}}" data-id="{{$p.ID}}" data-url="{{$p.URL}}" hx-get="/api/projects/{{$p.ID}}" hx-target="#project-detail" hx-swap="innerHTML">
- <div class="list-header">
- <span class="list-title">{{$p.Name}}</span>
- <span class="list-meta">{{techMeta $p.Tech}}</span>
- </div>
- <div class="list-subtitle">{{$p.Subtitle}}</div>
- </div>
- {{end}}
- </aside>
- <article class="detail-view">
- <div id="project-detail">
- {{if .Project}}
- {{template "project_detail" .}}
- {{else if gt (len .Projects) 0}}
- <p class="color-dim" data-project-placeholder="true">$ cat README.md</p>
- {{else}}
- <p class="color-dim">No projects available.</p>
- {{end}}
- </div>
- </article>
-</section>
-
-<style>{{inlineCSS "projects.css"}}</style>
-{{end}}
diff --git a/internal/web/renderer.go b/internal/web/renderer.go
@@ -0,0 +1,142 @@
+package web
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "path"
+ "strings"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/content"
+ "git.prime8.dev/moncheeta/prime8dev/internal/external"
+ "git.prime8.dev/moncheeta/prime8dev/internal/utils"
+)
+
+//go:embed templates/*.gohtml templates/partials/*.gohtml styles/*.css
+var templatesFS embed.FS
+
+type Data struct {
+ Title string
+ Description string
+ ActiveTab string
+ ContentTemplate string
+ About config.About
+ AboutNameASCII string
+ Experience []config.Experience
+ SelectedExperienceID string
+ CurrentJob *config.Experience
+ Projects []config.Project
+ SelectedProjectID string
+ Project *config.Project
+ ProjectLinkLabel string
+ ProjectReadme template.HTML
+ Blogs []content.Blog
+ SelectedBlogID string
+ Blog *content.Blog
+ WakaStats *external.WakaTimeStats
+ Tabs []Tab
+ SiteURL string
+}
+
+type Tab struct {
+ ID string
+ Label string
+ Num int
+}
+
+type Renderer struct {
+ tpl *template.Template
+}
+
+func New() (*Renderer, error) {
+ inlineStyles, err := loadInlineStyles()
+ if err != nil {
+ return nil, err
+ }
+
+ funcs := template.FuncMap{
+ "tabPath": utils.TabPath,
+ "tabURL": utils.TabURL,
+ "tagClass": utils.TagClass,
+ "techMeta": utils.TechMeta,
+ "formatMonthYear": utils.FormatMonthYear,
+ "formatEndDate": utils.FormatEndDate,
+ "formatDuration": utils.FormatDuration,
+ "durationHasYear": utils.DurationHasYear,
+ "timelineYear": utils.TimelineYear,
+ "toHTML": toHTML,
+ "inlineCSS": func(name string) template.CSS {
+ if css, ok := inlineStyles[name]; ok {
+ return css
+ }
+ return ""
+ },
+ }
+
+ tpl, err := template.New("site").Funcs(funcs).ParseFS(templatesFS, "templates/*.gohtml", "templates/partials/*.gohtml")
+ if err != nil {
+ return nil, fmt.Errorf("parse templates: %w", err)
+ }
+
+ return &Renderer{tpl: tpl}, nil
+}
+
+func (r *Renderer) Render(name string, data Data) (string, error) {
+ var b strings.Builder
+ if err := r.tpl.ExecuteTemplate(&b, name, data); err != nil {
+ return "", err
+ }
+ return b.String(), nil
+}
+
+func DefaultTabs() []Tab {
+ return []Tab{
+ {ID: "about", Label: "About", Num: 1},
+ {ID: "projects", Label: "Projects", Num: 2},
+ {ID: "experience", Label: "Experience", Num: 3},
+ {ID: "blog", Label: "Blog", Num: 4},
+ }
+}
+
+func BaseData(title, activeTab string, about config.About) Data {
+ return Data{
+ Title: title,
+ Description: fmt.Sprintf("Portfolio and blog of %s", about.Name),
+ ActiveTab: activeTab,
+ ContentTemplate: activeTab,
+ About: about,
+ AboutNameASCII: utils.AboutNameASCII(about.Name),
+ Tabs: DefaultTabs(),
+ SiteURL: config.SiteURL,
+ }
+}
+
+func toHTML(s string) template.HTML {
+ return template.HTML(s)
+}
+
+func loadInlineStyles() (map[string]template.CSS, error) {
+ entries, err := fs.ReadDir(templatesFS, "styles")
+ if err != nil {
+ return nil, fmt.Errorf("read styles: %w", err)
+ }
+
+ styles := make(map[string]template.CSS, len(entries))
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".css") {
+ continue
+ }
+
+ stylePath := path.Join("styles", entry.Name())
+ content, err := templatesFS.ReadFile(stylePath)
+ if err != nil {
+ return nil, fmt.Errorf("read style %s: %w", entry.Name(), err)
+ }
+
+ styles[entry.Name()] = template.CSS(string(content))
+ }
+
+ return styles, nil
+}
diff --git a/internal/view/scripts/app.js b/internal/web/scripts/app.js
diff --git a/internal/web/scripts/modules/about.js b/internal/web/scripts/modules/about.js
@@ -0,0 +1,184 @@
+import { getWakaTimeData, subscribeWakaTimeRefresh } from "./wakatime.js";
+
+let unsubscribeAboutRefresh = null;
+
+export function initAbout() {
+ initSSHTipCopy();
+
+ const statsContainer = document.getElementById("activity-stats");
+ const section = document.getElementById("activity-section");
+ if (!statsContainer || !section) return;
+
+ function hideActivity() {
+ section.style.display = "none";
+ }
+
+ async function loadActivityData(data = null) {
+ if (!section.isConnected) {
+ if (unsubscribeAboutRefresh) {
+ unsubscribeAboutRefresh();
+ unsubscribeAboutRefresh = null;
+ }
+ return;
+ }
+
+ try {
+ const stats = data || (await getWakaTimeData());
+
+ if (!stats || !stats.Weekly || !Array.isArray(stats.Weekly.Days)) {
+ hideActivity();
+ return;
+ }
+
+ const totalText = stats.Weekly.TotalText;
+ const dayBars = stats.Weekly.Days;
+ if (!totalText || dayBars.length === 0) {
+ hideActivity();
+ return;
+ }
+
+ const days = dayBars.map((bar) => ({
+ dayText: (bar.DayText || "").toLowerCase(),
+ timeText: bar.TimeText || "",
+ barWidthPercent:
+ bar.BarWidthPercent && typeof bar.BarWidthPercent === "string"
+ ? bar.BarWidthPercent.trim()
+ : "0%",
+ }));
+
+ renderWeeklyStats(statsContainer, totalText, days);
+ } catch {
+ hideActivity();
+ }
+ }
+
+ loadActivityData();
+
+ if (unsubscribeAboutRefresh) {
+ unsubscribeAboutRefresh();
+ }
+ unsubscribeAboutRefresh = subscribeWakaTimeRefresh((data) => {
+ void loadActivityData(data);
+ });
+}
+
+function initSSHTipCopy() {
+ const copyButton = document.querySelector("[data-ssh-copy]");
+ const commandElement = document.querySelector("[data-ssh-command]");
+ if (!copyButton || !commandElement || copyButton.dataset.bound === "true") return;
+
+ copyButton.dataset.bound = "true";
+ copyButton.addEventListener("click", async () => {
+ const command = (commandElement.textContent || "").trim();
+ if (!command) return;
+
+ const copied = await copyToClipboard(command);
+ const originalLabel = copyButton.textContent || "copy";
+ copyButton.textContent = copied ? "copied" : "failed";
+ copyButton.classList.toggle("copied", copied);
+
+ window.setTimeout(() => {
+ if (!copyButton.isConnected) return;
+ copyButton.textContent = originalLabel;
+ copyButton.classList.remove("copied");
+ }, 1200);
+ });
+}
+
+async function copyToClipboard(text) {
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ const input = document.createElement("textarea");
+ input.value = text;
+ input.setAttribute("readonly", "");
+ input.style.position = "absolute";
+ input.style.left = "-9999px";
+ document.body.appendChild(input);
+ input.select();
+ const copied = document.execCommand("copy");
+ document.body.removeChild(input);
+ return copied;
+}
+
+function renderWeeklyStats(statsContainer, totalText, days) {
+ statsContainer.innerHTML = "";
+
+ const width = 24;
+ const pre = document.createElement("pre");
+ pre.className = "weekly-ascii";
+
+ const totalLabel = document.createElement("span");
+ totalLabel.className = "weekly-total-label";
+ totalLabel.textContent = "total: ";
+
+ const totalValue = document.createElement("span");
+ totalValue.className = "weekly-total-value";
+ totalValue.textContent = totalText;
+
+ pre.appendChild(totalLabel);
+ pre.appendChild(totalValue);
+ pre.appendChild(document.createTextNode("\n\n"));
+
+ days.forEach((day, index) => {
+ const label = (day.dayText || "").slice(0, 3).padEnd(3, " ");
+ const percent = clampPercent(day.barWidthPercent);
+ const { filled, empty } = asciiBarSegments(percent, width);
+
+ const dayLabel = document.createElement("span");
+ dayLabel.className = "weekly-day-label";
+ dayLabel.textContent = `${label} `;
+
+ const barFrame = document.createElement("span");
+ barFrame.className = "weekly-day-bar-frame";
+ barFrame.appendChild(document.createTextNode("|"));
+
+ const barFilled = document.createElement("span");
+ barFilled.className = "weekly-day-bar-filled";
+ barFilled.textContent = filled;
+
+ const barEmpty = document.createElement("span");
+ barEmpty.className = "weekly-day-bar-empty";
+ barEmpty.textContent = empty;
+
+ const dayTime = document.createElement("span");
+ dayTime.className = "weekly-day-time";
+ dayTime.textContent = ` ${day.timeText}`;
+
+ barFrame.appendChild(barFilled);
+ barFrame.appendChild(barEmpty);
+ barFrame.appendChild(document.createTextNode("|"));
+
+ pre.appendChild(dayLabel);
+ pre.appendChild(barFrame);
+ pre.appendChild(dayTime);
+
+ if (index < days.length - 1) {
+ pre.appendChild(document.createTextNode("\n"));
+ }
+ });
+
+ statsContainer.appendChild(pre);
+}
+
+function clampPercent(value) {
+ const parsed = Number.parseFloat((value || "0").replace("%", ""));
+ if (!Number.isFinite(parsed)) return 0;
+ if (parsed < 0) return 0;
+ if (parsed > 100) return 100;
+ return parsed;
+}
+
+function asciiBarSegments(percent, width) {
+ const filled = Math.round((percent / 100) * width);
+ return {
+ filled: "#".repeat(filled),
+ empty: "-".repeat(width - filled),
+ };
+}
diff --git a/internal/web/scripts/modules/blog.js b/internal/web/scripts/modules/blog.js
@@ -0,0 +1,188 @@
+import { isEditableTarget } from "./dom.js";
+import { clearAppTitleDetail, setAppTitleDetail, updateAppTitle } from "./header.js";
+
+const blogHandlers = {
+ keydown: null,
+ popstate: null,
+};
+
+export function initBlog() {
+ const section = document.getElementById("blog");
+ if (!section) return;
+
+ const listView = section.querySelector("#blog-list-view");
+ const postView = section.querySelector("#blog-post-view");
+ const backBtn = section.querySelector(".blog-back-btn");
+ const listItems = Array.from(section.querySelectorAll(".blog-list-item"));
+ if (!listView || !postView || listItems.length === 0) return;
+
+ let selectedIndex = 0;
+ const hasPrefilledPost = Boolean(document.getElementById("blog-post-content")?.children.length);
+ let postOpen = !postView.classList.contains("hidden") || window.location.pathname.startsWith("/blog/");
+ window.isBlogPostOpen = () => postOpen;
+
+ if (postOpen) {
+ listView.classList.add("hidden");
+ postView.classList.remove("hidden");
+ } else {
+ listView.classList.remove("hidden");
+ postView.classList.add("hidden");
+ }
+
+ function pathBlogID() {
+ return decodeURIComponent(window.location.pathname.match(/^\/blog\/([^/]+)$/)?.[1] || "");
+ }
+
+ function itemByBlogID(blogID) {
+ if (!blogID) return null;
+ return listItems.find((item) => item.dataset.id === blogID) || null;
+ }
+
+ function select(index) {
+ selectedIndex = index;
+ listItems.forEach((item, i) => item.classList.toggle("selected", i === index));
+ listItems[index]?.scrollIntoView({ behavior: "smooth", block: "nearest" });
+
+ const detail = listItems[index]?.querySelector(".blog-list-title")?.textContent?.trim() || "";
+ setAppTitleDetail("blog", detail);
+ updateAppTitle("blog");
+
+ const selectedItem = listItems[index];
+ if (selectedItem) {
+ selectedItem.dispatchEvent(new Event("blog:selected", { bubbles: true }));
+ }
+ }
+
+ function openPost(blogID, updateHistory = true) {
+ postOpen = true;
+ listView.classList.add("hidden");
+ postView.classList.remove("hidden");
+ postView.scrollTop = 0;
+
+ if (blogID && updateHistory) {
+ const path = `/blog/${encodeURIComponent(blogID)}`;
+ if (window.location.pathname !== path) {
+ history.replaceState({ tab: "blog" }, "", path);
+ }
+ }
+
+ updateAppTitle("blog");
+ }
+
+ function closePost(updateHistory = true) {
+ postOpen = false;
+ listView.classList.remove("hidden");
+ postView.classList.add("hidden");
+ clearAppTitleDetail("blog");
+
+ if (updateHistory && window.location.pathname !== "/blog") {
+ history.replaceState({ tab: "blog" }, "", "/blog");
+ }
+
+ updateAppTitle("blog");
+ }
+
+ listItems.forEach((item, index) => {
+ if (item.dataset.boundClick === "1") return;
+ item.dataset.boundClick = "1";
+
+ item.addEventListener("click", () => {
+ select(index);
+ openPost(item.dataset.id, true);
+ });
+ });
+
+ if (backBtn && backBtn.dataset.boundClick !== "1") {
+ backBtn.dataset.boundClick = "1";
+ backBtn.addEventListener("click", () => closePost(true));
+ }
+
+ if (blogHandlers.keydown) {
+ document.removeEventListener("keydown", blogHandlers.keydown);
+ }
+
+ blogHandlers.keydown = (e) => {
+ if (!section.isConnected) return;
+ if (window.isLightboxOpen?.()) return;
+ if (isEditableTarget(e.target)) return;
+
+ if (!postOpen) {
+ switch (e.key) {
+ case "ArrowUp":
+ e.preventDefault();
+ if (selectedIndex > 0) select(selectedIndex - 1);
+ break;
+ case "ArrowDown":
+ e.preventDefault();
+ if (selectedIndex < listItems.length - 1) select(selectedIndex + 1);
+ break;
+ case "ArrowRight":
+ case "Enter":
+ e.preventDefault();
+ listItems[selectedIndex]?.click();
+ break;
+ default:
+ break;
+ }
+ } else {
+ switch (e.key) {
+ case "ArrowLeft":
+ case "Escape":
+ e.preventDefault();
+ closePost(true);
+ break;
+ case "ArrowUp":
+ e.preventDefault();
+ postView.scrollBy({ top: -80, behavior: "smooth" });
+ break;
+ case "ArrowDown":
+ e.preventDefault();
+ postView.scrollBy({ top: 80, behavior: "smooth" });
+ break;
+ default:
+ break;
+ }
+ }
+ };
+
+ document.addEventListener("keydown", blogHandlers.keydown);
+
+ if (blogHandlers.popstate) {
+ window.removeEventListener("popstate", blogHandlers.popstate);
+ }
+
+ blogHandlers.popstate = () => {
+ if (!section.isConnected) return;
+
+ const currentID = pathBlogID();
+ if (!currentID) {
+ closePost(false);
+ return;
+ }
+
+ const item = itemByBlogID(currentID);
+ if (!item) return;
+
+ const idx = Number(item.dataset.index || 0);
+ select(idx);
+ openPost(currentID, false);
+
+ if (!document.getElementById("blog-post-content")?.children.length) {
+ item.click();
+ }
+ };
+
+ window.addEventListener("popstate", blogHandlers.popstate);
+
+ const pathID = pathBlogID();
+ const selectedFromPath = itemByBlogID(pathID);
+ const initialItem = selectedFromPath || section.querySelector(".blog-list-item.selected") || listItems[0];
+ if (!initialItem) return;
+
+ const initialIndex = Number(initialItem.dataset.index || 0);
+ select(initialIndex);
+
+ if (postOpen && !hasPrefilledPost) {
+ initialItem.click();
+ }
+}
diff --git a/internal/view/scripts/modules/blogDetail.js b/internal/web/scripts/modules/blogDetail.js
diff --git a/internal/view/scripts/modules/dom.js b/internal/web/scripts/modules/dom.js
diff --git a/internal/web/scripts/modules/experience.js b/internal/web/scripts/modules/experience.js
@@ -0,0 +1,159 @@
+import { isEditableTarget } from "./dom.js";
+
+const experienceHandlers = {
+ keydown: null,
+ popstate: null,
+};
+
+export function initExperience() {
+ const section = document.getElementById("experience");
+ if (!section) return;
+
+ const cards = Array.from(section.querySelectorAll(".exp-card"));
+ const timelines = Array.from(section.querySelectorAll(".exp-timeline"));
+ const rows = Array.from(section.querySelectorAll(".exp-row"));
+ if (cards.length === 0) return;
+
+ let selectedIndex = 0;
+
+ function experienceIDAt(index) {
+ return cards[index]?.dataset.id || rows[index]?.dataset.id || "";
+ }
+
+ function indexFromPath() {
+ const id = decodeURIComponent(
+ window.location.pathname.match(/^\/experience\/([^/]+)$/)?.[1] || "",
+ );
+ if (!id) return -1;
+ return cards.findIndex((card) => card.dataset.id === id);
+ }
+
+ function updateExperienceURL(index) {
+ const id = experienceIDAt(index);
+ if (!id) return;
+
+ const path = `/experience/${encodeURIComponent(id)}`;
+ if (window.location.pathname !== path) {
+ history.replaceState({ tab: "experience" }, "", path);
+ }
+ }
+
+ function scrollSelectionIntoView(index) {
+ const target = rows[index] || cards[index];
+ if (!target) return;
+
+ const scroller = section.querySelector(".experience-detail");
+ if (!scroller || !scroller.contains(target)) {
+ target.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "nearest",
+ });
+ return;
+ }
+
+ const scrollerRect = scroller.getBoundingClientRect();
+ const targetRect = target.getBoundingClientRect();
+ const isAbove = targetRect.top < scrollerRect.top + 12;
+ const isBelow = targetRect.bottom > scrollerRect.bottom - 12;
+ if (!isAbove && !isBelow) return;
+
+ const targetTop =
+ targetRect.top - scrollerRect.top + scroller.scrollTop - 8;
+ scroller.scrollTo({ top: Math.max(targetTop, 0), behavior: "smooth" });
+ }
+
+ function select(index, updateURL = true, scrollIntoView = false) {
+ selectedIndex = index;
+ cards.forEach((card, i) => card.classList.toggle("selected", i === index));
+ timelines.forEach((timeline, i) =>
+ timeline.classList.toggle("active", i === index),
+ );
+
+ if (scrollIntoView) {
+ scrollSelectionIntoView(index);
+ }
+
+ if (updateURL) {
+ updateExperienceURL(index);
+ }
+ }
+
+ cards.forEach((card, i) => {
+ if (card.dataset.boundHover !== "1") {
+ card.dataset.boundHover = "1";
+ card.addEventListener("mouseenter", () => select(i));
+ }
+ });
+
+ timelines.forEach((timeline, i) => {
+ if (timeline.dataset.boundHover !== "1") {
+ timeline.dataset.boundHover = "1";
+ timeline.addEventListener("mouseenter", () => select(i));
+ }
+ });
+
+ if (experienceHandlers.keydown) {
+ document.removeEventListener("keydown", experienceHandlers.keydown);
+ }
+
+ experienceHandlers.keydown = (e) => {
+ if (!section.isConnected || window.isLightboxOpen?.()) return;
+ if (isEditableTarget(e.target)) return;
+
+ switch (e.key) {
+ case "ArrowUp":
+ e.preventDefault();
+ if (selectedIndex > 0) select(selectedIndex - 1, true, true);
+ break;
+ case "ArrowDown":
+ e.preventDefault();
+ if (selectedIndex < cards.length - 1)
+ select(selectedIndex + 1, true, true);
+ break;
+ default:
+ break;
+ }
+ };
+
+ document.addEventListener("keydown", experienceHandlers.keydown);
+
+ if (experienceHandlers.popstate) {
+ window.removeEventListener("popstate", experienceHandlers.popstate);
+ }
+
+ experienceHandlers.popstate = () => {
+ if (!section.isConnected) return;
+ const index = indexFromPath();
+ if (index >= 0) {
+ select(index, false);
+ }
+ };
+
+ window.addEventListener("popstate", experienceHandlers.popstate);
+
+ section
+ .querySelectorAll(".exp-image-hero, .exp-image-thumb")
+ .forEach((img) => {
+ if (img.dataset.boundClick === "1") return;
+ img.dataset.boundClick = "1";
+
+ img.addEventListener("click", () => {
+ const container = img.closest(".exp-card-images");
+ const allImages = container
+ ? Array.from(container.querySelectorAll("img")).map((i) => i.src)
+ : [];
+ const startIndex = allImages.indexOf(img.src);
+ window.openLightbox?.(allImages, startIndex);
+ });
+ });
+
+ const selectedCard = section.querySelector(".exp-card.selected") || cards[0];
+ const initialIndex = Math.max(cards.indexOf(selectedCard), 0);
+ const pathIndex = indexFromPath();
+ if (pathIndex >= 0) {
+ select(pathIndex, false);
+ } else {
+ select(initialIndex);
+ }
+}
diff --git a/internal/view/scripts/modules/header.js b/internal/web/scripts/modules/header.js
diff --git a/internal/web/scripts/modules/help.js b/internal/web/scripts/modules/help.js
@@ -0,0 +1,81 @@
+import { isEditableTarget } from "./dom.js";
+
+const SHORTCUTS = [
+ { key: "◀ ▶", description: "navigate tabs" },
+ { key: "▲ ▼", description: "switch list items" },
+ { key: "shift + ▲ ▼", description: "scroll item details" },
+ { key: "tab", description: "cycle tabs" },
+ { key: "1-4", description: "switch tabs" },
+ { key: "enter", description: "open item" },
+ { key: "?", description: "toggle help" },
+];
+
+const helpHandlers = {
+ keydown: null,
+};
+
+export function initHelp() {
+ const overlay = createHelpOverlay();
+
+ if (helpHandlers.keydown) {
+ document.removeEventListener("keydown", helpHandlers.keydown);
+ }
+
+ helpHandlers.keydown = (e) => {
+ if (isEditableTarget(e.target)) return;
+
+ switch (e.key) {
+ case "?":
+ e.preventDefault();
+ overlay.classList.toggle("visible");
+ break;
+ case "Escape":
+ if (overlay.classList.contains("visible")) {
+ e.preventDefault();
+ overlay.classList.remove("visible");
+ }
+ break;
+ default:
+ break;
+ }
+ };
+
+ document.addEventListener("keydown", helpHandlers.keydown);
+}
+
+function createHelpOverlay() {
+ const existing = document.getElementById("help-overlay");
+ if (existing) return existing;
+
+ const overlay = document.createElement("div");
+ overlay.id = "help-overlay";
+ overlay.setAttribute("aria-hidden", "true");
+
+ overlay.innerHTML = `
+ <div class="help-modal" role="dialog" aria-modal="true" aria-label="keyboard shortcuts">
+ <div class="help-header">
+ <span class="help-title">help</span>
+ <span class="help-close">press ? to close</span>
+ </div>
+ <div class="help-content">
+ ${SHORTCUTS.map(
+ (shortcut) => `
+ <div class="help-row">
+ <span class="help-key">${shortcut.key}</span>
+ <span class="help-desc">${shortcut.description}</span>
+ </div>
+ `,
+ ).join("")}
+ </div>
+ </div>
+ `;
+
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) {
+ overlay.classList.remove("visible");
+ }
+ });
+
+ document.body.appendChild(overlay);
+ return overlay;
+}
diff --git a/internal/web/scripts/modules/layout.js b/internal/web/scripts/modules/layout.js
@@ -0,0 +1,153 @@
+import {
+ getWakaTimeData,
+ startWakaTimeRefreshTimer,
+ subscribeWakaTimeRefresh,
+} from "./wakatime.js";
+
+const BOOT_CHARS = "▓▒░█▄▀■□▪▫●○◆◇";
+const SCRAMBLE_DURATION = 400;
+const SCRAMBLE_INTERVAL = 30;
+const HOLD_DURATION = 450;
+const FADE_DURATION = 300;
+
+let bootPlayed = false;
+let statusTimer = 0;
+let unsubscribeWakaFooter = null;
+
+export async function initLayout() {
+ const initialWakaDataPromise = getWakaTimeData();
+
+ await playBootAnimation();
+
+ startStatusClock();
+
+ await loadWakaFooter(await initialWakaDataPromise);
+ if (unsubscribeWakaFooter) {
+ unsubscribeWakaFooter();
+ }
+ unsubscribeWakaFooter = subscribeWakaTimeRefresh((data) => {
+ void loadWakaFooter(data);
+ });
+ startWakaTimeRefreshTimer();
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function scrambleText(length) {
+ return Array.from(
+ { length },
+ () => BOOT_CHARS[Math.floor(Math.random() * BOOT_CHARS.length)],
+ ).join("");
+}
+
+async function scrambleReveal(element, target, duration) {
+ const steps = Math.floor(duration / SCRAMBLE_INTERVAL);
+ const length = target.length;
+
+ for (let i = 0; i <= steps; i++) {
+ const progress = i / steps;
+ const revealedCount = Math.floor(progress * length);
+ const revealed = target.slice(0, revealedCount);
+ const scrambled = scrambleText(length - revealedCount);
+ element.textContent = revealed + scrambled;
+ await sleep(SCRAMBLE_INTERVAL);
+ }
+
+ element.textContent = target;
+}
+
+function createBootOverlay(title, subtitle) {
+ const overlay = document.createElement("div");
+ overlay.id = "boot-overlay";
+ overlay.innerHTML =
+ '<div class="boot-terminal"><div class="boot-text"><span class="boot-title"></span><span class="boot-subtitle"></span></div></div>';
+
+ const bootTitle = overlay.querySelector(".boot-title");
+ const bootSubtitle = overlay.querySelector(".boot-subtitle");
+ if (bootTitle) bootTitle.textContent = scrambleText(title.length);
+ if (bootSubtitle) bootSubtitle.textContent = scrambleText(subtitle.length);
+
+ document.body.appendChild(overlay);
+ return overlay;
+}
+
+async function playBootAnimation() {
+ const app = document.getElementById("app");
+ if (!app) return;
+
+ if (bootPlayed || app.classList.contains("ready")) {
+ bootPlayed = true;
+ app.classList.add("ready");
+ return;
+ }
+
+ const title = app.dataset.bootTitle || "prime8.dev";
+ const subtitle = app.dataset.bootSubtitle || "www.prime8.dev";
+
+ const overlay = createBootOverlay(title, subtitle);
+ const bootTitle = overlay.querySelector(".boot-title");
+ const bootSubtitle = overlay.querySelector(".boot-subtitle");
+
+ await sleep(100);
+ if (bootTitle) await scrambleReveal(bootTitle, title, SCRAMBLE_DURATION);
+ if (bootSubtitle)
+ await scrambleReveal(bootSubtitle, subtitle, SCRAMBLE_DURATION);
+
+ await sleep(HOLD_DURATION);
+ overlay.classList.add("fade-out");
+ await sleep(FADE_DURATION);
+
+ overlay.remove();
+ app.classList.add("ready", "boot-reveal");
+ bootPlayed = true;
+}
+
+function updateStatusTime() {
+ const el = document.getElementById("status-time");
+ if (!el) return;
+
+ const now = new Date();
+ const time = now.toLocaleTimeString();
+ const date = formatStatusDate(now);
+ el.textContent = `${date} ${time}`;
+}
+
+function formatStatusDate(date) {
+ const year = String(date.getFullYear());
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+function startStatusClock() {
+ if (statusTimer) {
+ clearTimeout(statusTimer);
+ }
+
+ const tick = () => {
+ updateStatusTime();
+ const delay = 1000 - (Date.now() % 1000) || 1000;
+ statusTimer = window.setTimeout(tick, delay);
+ };
+
+ tick();
+}
+
+async function loadWakaFooter(data = null) {
+ const link = document.getElementById("wakatime-link");
+ if (!link) return;
+
+ try {
+ const stats = data || (await getWakaTimeData());
+ if (!stats || !stats.AllTime || !stats.AllTime.TotalText) {
+ link.style.display = "none";
+ return;
+ }
+
+ link.textContent = stats.AllTime.TotalText;
+ } catch {
+ link.style.display = "none";
+ }
+}
diff --git a/internal/view/scripts/modules/lightbox.js b/internal/web/scripts/modules/lightbox.js
diff --git a/internal/web/scripts/modules/projects.js b/internal/web/scripts/modules/projects.js
@@ -0,0 +1,138 @@
+import { isEditableTarget } from "./dom.js";
+import { setAppTitleDetail, updateAppTitle } from "./header.js";
+
+const projectsHandlers = {
+ keydown: null,
+};
+
+export function initProjects() {
+ const section = document.getElementById("projects");
+ if (!section) return;
+
+ const items = Array.from(section.querySelectorAll(".list-item"));
+ if (items.length === 0) return;
+
+ const detailView = section.querySelector(".detail-view");
+
+ let selectedIndex = 0;
+
+ function readmeScroller() {
+ if (!detailView) return null;
+
+ const readme = detailView.querySelector(".readme-content");
+ if (readme && readme.scrollHeight > readme.clientHeight) {
+ return readme;
+ }
+
+ if (detailView.scrollHeight > detailView.clientHeight) {
+ return detailView;
+ }
+
+ return null;
+ }
+
+ function setSelected(selectedItem) {
+ items.forEach((item) => item.classList.toggle("selected", item === selectedItem));
+ selectedIndex = items.indexOf(selectedItem);
+ if (selectedIndex < 0) selectedIndex = 0;
+
+ selectedItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
+
+ const detail = selectedItem.querySelector(".list-title")?.textContent?.trim() || "";
+ setAppTitleDetail("projects", detail);
+ updateAppTitle("projects");
+ }
+
+ function updateProjectURL(projectID) {
+ if (!projectID) return;
+ const path = `/projects/${encodeURIComponent(projectID)}`;
+ if (window.location.pathname !== path) {
+ history.replaceState({ tab: "projects" }, "", path);
+ updateAppTitle("projects");
+ }
+ }
+
+ function loadDetailForItem(item) {
+ const url = item.getAttribute("hx-get");
+ if (!url) return;
+
+ updateProjectURL(item.dataset.id);
+
+ if (window.htmx && typeof window.htmx.ajax === "function") {
+ window.htmx.ajax("GET", url, "#project-detail");
+ return;
+ }
+
+ item.click();
+ }
+
+ function selectByIndex(index, loadDetail) {
+ if (index < 0 || index >= items.length) return;
+ setSelected(items[index]);
+ if (loadDetail) loadDetailForItem(items[index]);
+ }
+
+ function openExternalForIndex(index) {
+ if (index < 0 || index >= items.length) return;
+ const url = items[index]?.dataset.url || "";
+ if (!url) return;
+ window.open(url, "_blank", "noopener,noreferrer");
+ }
+
+ items.forEach((item, index) => {
+ if (item.dataset.boundClick === "1") return;
+ item.dataset.boundClick = "1";
+ item.addEventListener("click", () => {
+ selectedIndex = index;
+ setSelected(item);
+ updateProjectURL(item.dataset.id);
+ });
+ });
+
+ if (projectsHandlers.keydown) {
+ document.removeEventListener("keydown", projectsHandlers.keydown);
+ }
+
+ projectsHandlers.keydown = (e) => {
+ if (!section.isConnected) return;
+ if (isEditableTarget(e.target)) return;
+ if (window.isLightboxOpen?.()) return;
+
+ if ((e.key === "ArrowUp" || e.key === "ArrowDown") && e.shiftKey) {
+ const scroller = readmeScroller();
+ if (scroller) {
+ e.preventDefault();
+ scroller.scrollBy({ top: e.key === "ArrowUp" ? -80 : 80, behavior: "smooth" });
+ return;
+ }
+ }
+
+ switch (e.key) {
+ case "ArrowUp":
+ e.preventDefault();
+ selectByIndex(selectedIndex - 1, true);
+ break;
+ case "ArrowDown":
+ e.preventDefault();
+ selectByIndex(selectedIndex + 1, true);
+ break;
+ case "Enter":
+ e.preventDefault();
+ openExternalForIndex(selectedIndex);
+ break;
+ default:
+ break;
+ }
+ };
+
+ document.addEventListener("keydown", projectsHandlers.keydown);
+
+ const initialItem = section.querySelector(".list-item.selected") || items[0];
+ setSelected(initialItem);
+
+ const detailContainer = document.getElementById("project-detail");
+ const needsInitialFetch = detailContainer?.querySelector("[data-project-placeholder='true']");
+ if (needsInitialFetch) {
+ setTimeout(() => loadDetailForItem(initialItem), 0);
+ }
+}
diff --git a/internal/view/scripts/modules/wakatime.js b/internal/web/scripts/modules/wakatime.js
diff --git a/internal/web/styles/about.css b/internal/web/styles/about.css
@@ -0,0 +1,364 @@
+.about-layout {
+ display: flex;
+ width: 100%;
+ height: 100%;
+}
+
+.about-left {
+ width: 340px;
+ flex-shrink: 0;
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ padding: 16px;
+ gap: 16px;
+ overflow-y: auto;
+}
+
+.about-right {
+ flex: 1;
+ padding: 24px 32px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ gap: 14px;
+}
+
+.profile-image {
+ width: 100%;
+ max-width: 300px;
+ height: auto;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+}
+
+.contact-box {
+ border: 1px solid var(--border);
+ padding: 12px;
+ max-width: 300px;
+}
+
+.about-sidebar-note {
+ margin-top: auto;
+ max-width: 300px;
+ border: 1px solid var(--accent);
+ background: linear-gradient(180deg, var(--bg-alt) 0%, var(--bg) 100%);
+ padding: 10px 12px;
+ font-size: var(--fs-12);
+}
+
+.about-sidebar-note-title {
+ color: var(--accent);
+ font-size: var(--fs-13);
+ margin-bottom: 6px;
+}
+
+.about-sidebar-note-desc {
+ color: var(--fg-dim);
+ margin-bottom: 4px;
+}
+
+.about-sidebar-note-command-row {
+ display: flex;
+ gap: 8px;
+ align-items: stretch;
+}
+
+.about-sidebar-note-command {
+ margin: 0;
+ flex: 1;
+ border: 1px solid var(--border);
+ background: var(--bg);
+ padding: 6px 8px;
+ color: var(--light-blue);
+ font-size: var(--fs-13);
+ white-space: nowrap;
+ overflow-x: auto;
+}
+
+.about-sidebar-copy-btn {
+ border: 1px solid var(--accent-dim);
+ background: var(--bg-alt);
+ color: var(--fg);
+ padding: 0 10px;
+ font-family: inherit;
+ font-size: var(--fs-12);
+ cursor: pointer;
+}
+
+.about-sidebar-copy-btn:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.about-sidebar-copy-btn.copied {
+ border-color: var(--green);
+ color: var(--green);
+}
+
+.box-title {
+ color: var(--accent);
+ font-size: var(--fs-12);
+ margin-bottom: 4px;
+ text-align: left;
+}
+
+.shell-prompt {
+ color: var(--fg-dim);
+ font-size: var(--fs-14);
+ margin-bottom: 3px;
+}
+
+.shell-user {
+ color: var(--green);
+}
+
+.shell-dollar {
+ color: var(--green);
+}
+
+.shell-host {
+ color: var(--accent);
+}
+
+.shell-path {
+ color: var(--cyan);
+}
+
+.shell-cmd {
+ color: var(--fg);
+}
+
+.name-ascii {
+ margin: 0;
+ color: var(--fg);
+ font-size: clamp(6px, 0.7vw + 4px, 12px);
+ line-height: 1;
+ white-space: pre;
+ overflow: clip;
+}
+
+@media (max-width: 1024px) {
+ .name-ascii {
+ font-size: clamp(6px, 0.5vw + 4px, var(--fs-9));
+ line-height: 1;
+ }
+}
+
+.contact-item {
+ display: flex;
+ margin-bottom: 4px;
+ font-size: var(--fs-13);
+ min-width: 0;
+}
+
+.contact-item .label {
+ color: var(--fg-dim);
+ width: 70px;
+ flex-shrink: 0;
+}
+
+.contact-item a {
+ color: var(--light-blue);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ direction: rtl;
+ text-align: left;
+}
+
+.contact-item a:hover {
+ opacity: 0.8;
+}
+
+.section {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.section .dim {
+ color: var(--fg-dim);
+ font-size: inherit;
+}
+
+.section .bio {
+ color: var(--fg);
+ font-size: var(--fs-14);
+ font-style: normal;
+ line-height: 1.6;
+ margin: 8px 0 0;
+}
+
+.info-columns {
+ display: flex;
+ gap: 30px;
+}
+
+.info-column {
+ flex: 1;
+}
+
+.info-row {
+ display: flex;
+ font-size: var(--fs-14);
+ margin-bottom: 2px;
+}
+
+.info-row .label {
+ color: var(--fg-dim);
+ width: 100px;
+ flex-shrink: 0;
+}
+
+.tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.weekly-stats {
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ padding: 12px;
+}
+
+.weekly-ascii {
+ margin: 0;
+ color: var(--fg);
+ font-size: var(--fs-12);
+ line-height: 1.4;
+ white-space: pre;
+ overflow-x: auto;
+ font-variant-ligatures: none;
+}
+
+.weekly-ascii .weekly-total-label {
+ color: var(--fg);
+}
+
+.weekly-ascii .weekly-total-value {
+ color: var(--cyan);
+}
+
+.weekly-ascii .weekly-day-label,
+.weekly-ascii .weekly-day-time {
+ color: var(--fg-dim);
+}
+
+.weekly-ascii .weekly-day-bar-frame {
+ color: var(--fg);
+}
+
+.weekly-ascii .weekly-day-bar-filled {
+ color: var(--fg);
+}
+
+.weekly-ascii .weekly-day-bar-empty {
+ color: var(--border);
+}
+
+.weekly-stats .loading-text {
+ color: var(--fg-dim);
+ font-style: italic;
+ text-align: center;
+ padding: 20px 0;
+}
+
+.weekly-total {
+ font-size: var(--fs-16);
+ font-weight: 500;
+ color: var(--accent);
+ margin-bottom: 12px;
+}
+
+.weekly-days {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.weekly-day-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: var(--fs-12);
+}
+
+.weekly-day-name {
+ width: 32px;
+ flex-shrink: 0;
+ color: var(--fg-dim);
+}
+
+.weekly-day-bar-container {
+ flex: 1;
+ height: 8px;
+ background: var(--bg);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.weekly-day-bar {
+ height: 100%;
+ background: var(--accent);
+ border-radius: 2px;
+}
+
+.weekly-day-time {
+ min-width: 50px;
+ flex-shrink: 0;
+ text-align: right;
+ color: var(--fg-dim);
+ white-space: nowrap;
+}
+
+@media (max-width: 768px) {
+ .about-layout {
+ flex-direction: column;
+ }
+
+ .about-left {
+ flex-direction: row;
+ width: 100%;
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .profile-image {
+ width: 120px;
+ height: 120px;
+ object-fit: cover;
+ }
+
+ .contact-box {
+ flex: 1;
+ max-width: none;
+ min-width: 0;
+ }
+
+ .about-sidebar-note {
+ display: none;
+ }
+
+ .about-right {
+ padding: 16px;
+ display: block;
+ }
+
+ .section {
+ margin-bottom: 16px;
+ }
+
+ .name-ascii {
+ font-size: clamp(5px, 0.45vw + 3.2px, var(--fs-8));
+ line-height: 1;
+ }
+
+ .info-columns {
+ flex-direction: column;
+ gap: 12px;
+ }
+}
diff --git a/internal/view/styles/blog.css b/internal/web/styles/blog.css
diff --git a/internal/view/styles/blog_detail.css b/internal/web/styles/blog_detail.css
diff --git a/internal/view/styles/experience.css b/internal/web/styles/experience.css
diff --git a/internal/view/styles/footer.css b/internal/web/styles/footer.css
diff --git a/internal/view/styles/header.css b/internal/web/styles/header.css
diff --git a/internal/view/styles/layout.css b/internal/web/styles/layout.css
diff --git a/internal/view/styles/lightbox.css b/internal/web/styles/lightbox.css
diff --git a/internal/view/styles/project_detail.css b/internal/web/styles/project_detail.css
diff --git a/internal/view/styles/projects.css b/internal/web/styles/projects.css
diff --git a/internal/web/templates/layout.gohtml b/internal/web/templates/layout.gohtml
@@ -0,0 +1,64 @@
+{{define "layout"}}
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{.Title}} | {{.About.Name}}</title>
+ <meta name="description" content="{{.Description}}">
+ <link rel="canonical" href="{{tabURL .ActiveTab}}">
+ <link rel="alternate" hreflang="en" href="{{tabURL .ActiveTab}}">
+ <link rel="alternate" hreflang="x-default" href="{{tabURL .ActiveTab}}">
+ <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin="anonymous">
+ <link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
+ <link rel="preload" href="https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.0.18/files/jetbrains-mono-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin="anonymous">
+ <meta property="og:type" content="website">
+ <meta property="og:url" content="{{tabURL .ActiveTab}}">
+ <meta property="og:title" content="{{.Title}} | {{.About.Name}}">
+ <meta property="og:description" content="{{.Description}}">
+ <meta property="og:image" content="https://www.prime8.dev/static/me.jpg">
+ <meta property="og:image:width" content="1200">
+ <meta property="og:image:height" content="630">
+ <meta property="og:site_name" content="{{.About.Name}}">
+ <meta property="og:locale" content="en_US">
+ <meta name="twitter:card" content="summary_large_image">
+ <meta name="twitter:url" content="{{tabURL .ActiveTab}}">
+ <meta name="twitter:title" content="{{.Title}} | {{.About.Name}}">
+ <meta name="twitter:description" content="{{.Description}}">
+ <meta name="twitter:image" content="https://www.prime8.dev/static/me.jpg">
+ <meta name="author" content="{{.About.Name}}">
+ <meta name="robots" content="index, follow">
+ <meta name="theme-color" content="#0d0d0d">
+ <link rel="icon" type="image/png" href="/static/favicon.png">
+ <link rel="apple-touch-icon" href="/static/favicon.png">
+ <link rel="manifest" href="/static/site.webmanifest">
+ <script defer src="https://metrics.prime8.dev/script.js" data-website-id="d3032378-7959-4411-92ec-f67e20399fe4"></script>
+ <script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
+ <script src="https://unpkg.com/htmx-ext-preload@2.1.0/preload.js" crossorigin="anonymous"></script>
+ <style>{{inlineCSS "layout.css"}}</style>
+</head>
+<body>
+ <div id="app" data-boot-title="{{.About.Name}}" data-boot-subtitle="www.prime8.dev" hx-ext="preload">
+ {{template "header" .}}
+ <main id="content">
+ {{if eq .ContentTemplate "about"}}
+ {{template "about" .}}
+ {{else if eq .ContentTemplate "projects"}}
+ {{template "projects" .}}
+ {{else if eq .ContentTemplate "experience"}}
+ {{template "experience" .}}
+ {{else if eq .ContentTemplate "blog"}}
+ {{template "blog" .}}
+ {{end}}
+ </main>
+ {{template "footer" .}}
+ </div>
+
+ {{template "lightbox" .}}
+
+ <script src="/static/app.js" defer></script>
+ <script type="application/ld+json">{"@context":"https://schema.org","@type":"Person","name":"{{.About.Name}}"}</script>
+ <script type="application/ld+json">{"@context":"https://schema.org","@type":"WebSite","name":"{{.About.Name}}","url":"https://www.prime8.dev"}</script>
+</body>
+</html>
+{{end}}
diff --git a/internal/web/templates/partials/about.gohtml b/internal/web/templates/partials/about.gohtml
@@ -0,0 +1,78 @@
+{{define "about"}}
+<section id="about" class="page active">
+ <div class="about-layout">
+ <div class="about-left">
+ <img class="profile-image" src="/static/me.jpg" alt="damian myrda - software engineer and high school senior from chicago" loading="eager">
+ <div class="contact-box">
+ <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">cat contact</span></div>
+ <div class="contact-item"><span class="label">email:</span><a href="mailto:damian@prime8.dev">damian@prime8.dev</a></div>
+ <div class="contact-item"><span class="label">gitlab:</span><a href="https://git.prime8.dev/moncheeta" target="_blank" rel="noopener noreferrer me">git.prime8.dev/moncheeta</a></div>
+ <div class="contact-item"><span class="label">linkedin:</span><a href="https://linkedin.com/in/damian-myrda" target="_blank" rel="noopener noreferrer me">linkedin.com/in/damian-myrda</a></div>
+ </div>
+ <div class="about-sidebar-note" aria-label="SSH endpoint">
+ <div class="about-sidebar-note-title">Explore this portfolio over SSH</div>
+ <div class="about-sidebar-note-desc">Open your terminal and connect:</div>
+ <div class="about-sidebar-note-command-row">
+ <pre class="about-sidebar-note-command" data-ssh-command><code>ssh ssh.prime8.dev</code></pre>
+ <button type="button" class="about-sidebar-copy-btn" data-ssh-copy>copy</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="about-right">
+ <div class="section">
+ <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">whoami</span></div>
+ <pre class="name-ascii" aria-label="{{.About.Name}}">{{.AboutNameASCII}}</pre>
+ <p class="bio">{{.About.Bio}}</p>
+ </div>
+
+ <div class="section">
+ <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">cat info</span></div>
+ <div class="info-columns">
+ <div class="info-column">
+ <div class="info-row"><span class="label">location:</span>{{.About.Location}}</div>
+ <div class="info-row"><span class="label">school:</span>{{.About.School}}</div>
+ <div class="info-row"><span class="label">graduating:</span>{{.About.Graduating}}</div>
+ </div>
+ {{with .CurrentJob}}
+ <div class="info-column">
+ <div class="info-row"><span class="label">company:</span><a href="/experience/{{.ID}}" hx-get="/experience/{{.ID}}" hx-target="#content" hx-push-url="true">{{.Company}}</a></div>
+ <div class="info-row"><span class="label">position:</span>{{.Role}}</div>
+ </div>
+ {{end}}
+ </div>
+ </div>
+
+ <div class="section">
+ <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">ls interests/</span></div>
+ <div class="tags">
+ {{range .About.Interests}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}}
+ </div>
+ </div>
+
+ <div class="section">
+ <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">ls tools/</span></div>
+ <div class="tags">
+ {{range .About.Tools}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}}
+ </div>
+ </div>
+
+ <div class="section">
+ <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">ls languages/</span></div>
+ <div class="tags">
+ {{range .About.Languages}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}}
+ </div>
+ </div>
+
+ <div class="section" id="activity-section">
+ <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">wakatime --week</span></div>
+ <div class="weekly-stats" id="activity-stats">
+ <div class="loading-text">loading stats...</div>
+ </div>
+ </div>
+ </div>
+ </div>
+</section>
+
+<style>{{inlineCSS "about.css"}}</style>
+{{end}}
diff --git a/internal/web/templates/partials/blog.gohtml b/internal/web/templates/partials/blog.gohtml
@@ -0,0 +1,46 @@
+{{define "blog"}}
+<section id="blog" class="page">
+ {{if eq (len .Blogs) 0}}
+ <div class="blog-empty">
+ <p class="color-dim">~/blog is empty</p>
+ <p class="color-dim">check back soon</p>
+ </div>
+ {{else}}
+ <div id="blog-list-view" class="blog-list-view{{if .Blog}} hidden{{end}}">
+ <div class="blog-fs-shell">
+ <div class="blog-fs-header">~/blog $ ls -l</div>
+ <div class="blog-list-columns">
+ <span class="col-mode">mode</span>
+ <span>date</span>
+ <span>name</span>
+ <span class="col-desc">desc</span>
+ <span class="col-arrow"></span>
+ </div>
+ <div class="blog-list-container" preload-images="true">
+ {{range $i, $blog := .Blogs}}
+ <div class="blog-list-item{{if $.SelectedBlogID}}{{if eq $blog.ID $.SelectedBlogID}} selected{{end}}{{else}}{{if eq $i 0}} selected{{end}}{{end}}" data-index="{{$i}}" data-id="{{$blog.ID}}" hx-get="/api/blog/{{$blog.ID}}" hx-target="#blog-post-content" hx-swap="innerHTML" preload="blog:selected">
+ <span class="blog-list-mode">-rw-r--r--</span>
+ <span class="blog-list-date">{{$blog.Date.Format "2006-01-02"}}</span>
+ <span class="blog-list-name">
+ <span class="blog-list-file">{{$blog.ID}}.md</span>
+ <span class="blog-list-title">{{$blog.Title}}</span>
+ </span>
+ <span class="blog-list-desc" title="{{$blog.Description}}">{{$blog.Description}}</span>
+ <span class="blog-list-arrow">-></span>
+ </div>
+ {{end}}
+ </div>
+ </div>
+ </div>
+
+ <div id="blog-post-view" class="blog-post-view{{if not .Blog}} hidden{{end}}">
+ <div class="blog-post-container">
+ <button class="blog-back-btn">cd ..</button>
+ <div id="blog-post-content">{{if .Blog}}{{template "blog_detail" .}}{{end}}</div>
+ </div>
+ </div>
+ {{end}}
+</section>
+
+<style>{{inlineCSS "blog.css"}}</style>
+{{end}}
diff --git a/internal/web/templates/partials/blog_detail.gohtml b/internal/web/templates/partials/blog_detail.gohtml
@@ -0,0 +1,28 @@
+{{define "blog_detail"}}
+{{if .Blog}}
+<article class="blog-article" data-id="{{.Blog.ID}}" data-blog-id="{{.Blog.ID}}">
+ <header class="blog-article-header">
+ <div class="blog-article-header-top">
+ <h1 class="blog-article-title">~/blog/{{.Blog.ID}}.md</h1>
+ <button class="blog-share-btn blog-share-btn-desktop" data-blog-id="{{.Blog.ID}}" data-blog-title="{{.Blog.ID}}.md">[Share]</button>
+ </div>
+ <div class="blog-article-meta">
+ <time class="blog-article-date" datetime="{{.Blog.Date.Format "2006-01-02"}}">{{.Blog.Date.Format "2006-01-02"}}</time>
+ <button class="blog-share-btn blog-share-btn-mobile" data-blog-id="{{.Blog.ID}}" data-blog-title="{{.Blog.ID}}.md">[Share]</button>
+ </div>
+ </header>
+
+ <div class="blog-article-content" data-blog-id="{{.Blog.ID}}">
+ {{if .Blog.URL}}
+ <a class="external-link" href="{{.Blog.URL}}" target="_blank" rel="noopener noreferrer">Read on external site -></a>
+ {{else if .Blog.HTML}}
+ {{toHTML .Blog.HTML}}
+ {{else}}
+ <p class="color-dim">Content not available</p>
+ {{end}}
+ </div>
+</article>
+
+<style>{{inlineCSS "blog_detail.css"}}</style>
+{{end}}
+{{end}}
diff --git a/internal/view/templates/partials/experience.gohtml b/internal/web/templates/partials/experience.gohtml
diff --git a/internal/view/templates/partials/footer.gohtml b/internal/web/templates/partials/footer.gohtml
diff --git a/internal/web/templates/partials/header.gohtml b/internal/web/templates/partials/header.gohtml
@@ -0,0 +1,21 @@
+{{define "header"}}
+<header id="tabs" data-active-tab="{{.ActiveTab}}" data-tabs="about,projects,experience,blog">
+ <nav class="tab-nav" aria-label="main navigation" preload="preload:init" preload-images="true">
+ {{range .Tabs}}
+ <a href="{{tabPath .ID}}" class="tab{{if eq .ID $.ActiveTab}} active{{end}}" data-tab="{{.ID}}" data-label="{{.ID}}" hx-get="{{tabPath .ID}}" hx-target="#content" hx-push-url="true">
+ <span class="tab-num">[{{.Num}}] </span>{{.ID}}
+ </a>
+ {{end}}
+ </nav>
+ <div class="tab-spacer"></div>
+ <div class="status" id="header-status">
+ <span class="dpad">
+ <span class="dpad-row"><span class="dpad-key">▲</span></span>
+ <span class="dpad-row"><span class="dpad-key">◀</span><span class="dpad-key">▼</span><span class="dpad-key">▶</span></span>
+ </span>
+ <span class="navigate"> navigate · tab cycle · ? help</span>
+ </div>
+</header>
+
+<style>{{inlineCSS "header.css"}}</style>
+{{end}}
diff --git a/internal/view/templates/partials/lightbox.gohtml b/internal/web/templates/partials/lightbox.gohtml
diff --git a/internal/view/templates/partials/project_detail.gohtml b/internal/web/templates/partials/project_detail.gohtml
diff --git a/internal/web/templates/partials/projects.gohtml b/internal/web/templates/partials/projects.gohtml
@@ -0,0 +1,28 @@
+{{define "projects"}}
+<section id="projects" class="page">
+ <aside class="list-view" aria-label="Project list" preload="preload:init" preload-images="true">
+ {{range $i, $p := .Projects}}
+ <div class="list-item{{if $.SelectedProjectID}}{{if eq $p.ID $.SelectedProjectID}} selected{{end}}{{else}}{{if eq $i 0}} selected{{end}}{{end}}" data-index="{{$i}}" data-id="{{$p.ID}}" data-url="{{$p.URL}}" hx-get="/api/projects/{{$p.ID}}" hx-target="#project-detail" hx-swap="innerHTML">
+ <div class="list-header">
+ <span class="list-title">{{$p.Name}}</span>
+ <span class="list-meta">{{techMeta $p.Tech}}</span>
+ </div>
+ <div class="list-subtitle">{{$p.Subtitle}}</div>
+ </div>
+ {{end}}
+ </aside>
+ <article class="detail-view">
+ <div id="project-detail">
+ {{if .Project}}
+ {{template "project_detail" .}}
+ {{else if gt (len .Projects) 0}}
+ <p class="color-dim" data-project-placeholder="true">$ cat README.md</p>
+ {{else}}
+ <p class="color-dim">No projects available.</p>
+ {{end}}
+ </div>
+ </article>
+</section>
+
+<style>{{inlineCSS "projects.css"}}</style>
+{{end}}