prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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:
D.air.toml | 18------------------
A.air.web.toml | 18++++++++++++++++++
M.gitignore | 3+++
AJustfile | 13+++++++++++++
MREADME.md | 13+++++--------
Mcmd/buildjs/main.go | 2+-
Acmd/server/handlers/blog.go | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/handlers/cache.go -> cmd/server/handlers/cache.go | 0
Acmd/server/handlers/experience.go | 46++++++++++++++++++++++++++++++++++++++++++++++
Acmd/server/handlers/handler.go | 30++++++++++++++++++++++++++++++
Acmd/server/handlers/home.go | 24++++++++++++++++++++++++
Acmd/server/handlers/projects.go | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/server/handlers/render.go | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/server/handlers/sitemap.go | 42++++++++++++++++++++++++++++++++++++++++++
Rinternal/handlers/wakatime.go -> cmd/server/handlers/wakatime.go | 0
Acmd/server/logging.go | 28++++++++++++++++++++++++++++
Mcmd/server/main.go | 66++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Acmd/tui/main.go | 18++++++++++++++++++
Menv.example | 1+
Mgenerate_cloud_config.sh | 2+-
Mgo.mod | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mgo.sum | 166++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Minternal/config/projects.go | 2++
Minternal/content/blogs.go | 121+++++++++++++++++++++++++++++++++++++++----------------------------------------
Ainternal/content/projects.go | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/external/gitlab.go | 55+++++++++++++++++++++++--------------------------------
Minternal/external/wakatime.go | 11+++++------
Dinternal/handlers/blog.go | 74--------------------------------------------------------------------------
Dinternal/handlers/experience.go | 46----------------------------------------------
Dinternal/handlers/handler.go | 57---------------------------------------------------------
Dinternal/handlers/home.go | 24------------------------
Dinternal/handlers/projects.go | 91-------------------------------------------------------------------------------
Dinternal/handlers/render.go | 54------------------------------------------------------
Dinternal/handlers/sitemap.go | 42------------------------------------------
Ainternal/ssh/limiter.go | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/ssh/network.go | 16++++++++++++++++
Ainternal/ssh/route.go | 42++++++++++++++++++++++++++++++++++++++++++
Ainternal/ssh/server.go | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/about_ascii.go | 44++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/about_render.go | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/blog_markdown_render.go | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/boot.go | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/content.go | 482+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/helpers.go | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/keys.go | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/layout.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/local.go | 18++++++++++++++++++
Ainternal/tui/markdown.go | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/program.go | 32++++++++++++++++++++++++++++++++
Ainternal/tui/project_markdown_render.go | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/projects_readme.go | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/styles.go | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/tags.go | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/types.go | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/update.go | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/view.go | 287+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/waka_footer.go | 41+++++++++++++++++++++++++++++++++++++++++
Ainternal/utils/ascii.go | 42++++++++++++++++++++++++++++++++++++++++++
Minternal/utils/markdown.go | 43++++++++++++++++++-------------------------
Dinternal/view/ascii.go | 34----------------------------------
Dinternal/view/renderer.go | 142-------------------------------------------------------------------------------
Dinternal/view/scripts/modules/about.js | 93-------------------------------------------------------------------------------
Dinternal/view/scripts/modules/blog.js | 183-------------------------------------------------------------------------------
Dinternal/view/scripts/modules/experience.js | 121-------------------------------------------------------------------------------
Dinternal/view/scripts/modules/help.js | 80-------------------------------------------------------------------------------
Dinternal/view/scripts/modules/layout.js | 144-------------------------------------------------------------------------------
Dinternal/view/scripts/modules/projects.js | 138-------------------------------------------------------------------------------
Dinternal/view/styles/about.css | 276-------------------------------------------------------------------------------
Dinternal/view/templates/layout.gohtml | 63---------------------------------------------------------------
Dinternal/view/templates/partials/about.gohtml | 70----------------------------------------------------------------------
Dinternal/view/templates/partials/blog.gohtml | 46----------------------------------------------
Dinternal/view/templates/partials/blog_detail.gohtml | 28----------------------------
Dinternal/view/templates/partials/header.gohtml | 21---------------------
Dinternal/view/templates/partials/projects.gohtml | 28----------------------------
Ainternal/web/renderer.go | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/view/scripts/app.js -> internal/web/scripts/app.js | 0
Ainternal/web/scripts/modules/about.js | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/scripts/modules/blog.js | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/view/scripts/modules/blogDetail.js -> internal/web/scripts/modules/blogDetail.js | 0
Rinternal/view/scripts/modules/dom.js -> internal/web/scripts/modules/dom.js | 0
Ainternal/web/scripts/modules/experience.js | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/view/scripts/modules/header.js -> internal/web/scripts/modules/header.js | 0
Ainternal/web/scripts/modules/help.js | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/scripts/modules/layout.js | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/view/scripts/modules/lightbox.js -> internal/web/scripts/modules/lightbox.js | 0
Ainternal/web/scripts/modules/projects.js | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/view/scripts/modules/wakatime.js -> internal/web/scripts/modules/wakatime.js | 0
Ainternal/web/styles/about.css | 364+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/view/styles/blog.css -> internal/web/styles/blog.css | 0
Rinternal/view/styles/blog_detail.css -> internal/web/styles/blog_detail.css | 0
Rinternal/view/styles/experience.css -> internal/web/styles/experience.css | 0
Rinternal/view/styles/footer.css -> internal/web/styles/footer.css | 0
Rinternal/view/styles/header.css -> internal/web/styles/header.css | 0
Rinternal/view/styles/layout.css -> internal/web/styles/layout.css | 0
Rinternal/view/styles/lightbox.css -> internal/web/styles/lightbox.css | 0
Rinternal/view/styles/project_detail.css -> internal/web/styles/project_detail.css | 0
Rinternal/view/styles/projects.css -> internal/web/styles/projects.css | 0
Ainternal/web/templates/layout.gohtml | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/templates/partials/about.gohtml | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/templates/partials/blog.gohtml | 46++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/templates/partials/blog_detail.gohtml | 28++++++++++++++++++++++++++++
Rinternal/view/templates/partials/experience.gohtml -> internal/web/templates/partials/experience.gohtml | 0
Rinternal/view/templates/partials/footer.gohtml -> internal/web/templates/partials/footer.gohtml | 0
Ainternal/web/templates/partials/header.gohtml | 21+++++++++++++++++++++
Rinternal/view/templates/partials/lightbox.gohtml -> internal/web/templates/partials/lightbox.gohtml | 0
Rinternal/view/templates/partials/project_detail.gohtml -> internal/web/templates/partials/project_detail.gohtml | 0
Ainternal/web/templates/partials/projects.gohtml | 28++++++++++++++++++++++++++++
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 &copy, 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 &copy, 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}}