prime8dev

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

commit 2d9548063db09b652733f64f8a7bec2468ad45f2
parent bc6fd537d8c40759062e3872d96b2dda18bf0cc9
Author: Damian Myrda <damian@prime8.dev>
Date:   Wed, 21 Oct 2026 01:56:42 +0000

feat(about): fastfetch-style layout

The about page becomes a fastfetch screen with the photo as the logo
and key: value rows next to it (name, role, location, uptime, setup,
langs, contact), on the web and in the TUI.

- uptime is computed from the birthday, uptime(1) style
- langs are colored from the tag table; links are blue and dotted
- drop the figlet name (and the ascii-art dependency), the left
  column with the contact box and SSH note, and the info table
- interests refreshed, hobbies added, tools replaced by setup rows

Diffstat:
Mgo.mod | 1-
Mgo.sum | 2--
Minternal/config/about.go | 22+++++++++++++++++++---
Minternal/tui/about_ascii.go | 18+-----------------
Minternal/tui/about_render.go | 43+++----------------------------------------
Minternal/tui/content.go | 140+++++++++++++++++++++++++++++++++++++------------------------------------------
Minternal/tui/helpers.go | 23-----------------------
Minternal/tui/layout.go | 9---------
Minternal/tui/styles.go | 1+
Minternal/tui/types.go | 54++++++++++++++++++++++++------------------------------
Minternal/tui/update.go | 7-------
Minternal/tui/view.go | 28----------------------------
Dinternal/utils/ascii.go | 42------------------------------------------
Ainternal/utils/uptime.go | 21+++++++++++++++++++++
Minternal/web/renderer.go | 6++++--
Minternal/web/scripts/modules/about.js | 48+-----------------------------------------------
Minternal/web/styles/about.css | 253++++++++++++++++++-------------------------------------------------------------
Minternal/web/templates/partials/about.gohtml | 99+++++++++++++++++++++++++++++++++----------------------------------------------
18 files changed, 237 insertions(+), 580 deletions(-)

diff --git a/go.mod b/go.mod @@ -19,7 +19,6 @@ require ( github.com/evanw/esbuild v0.27.4 github.com/joho/godotenv v1.5.1 github.com/muesli/termenv v0.16.0 - github.com/romance-dev/ascii-art v1.0.1 golang.org/x/crypto v0.36.0 ) diff --git a/go.sum b/go.sum @@ -126,8 +126,6 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ 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= diff --git a/internal/config/about.go b/internal/config/about.go @@ -1,5 +1,14 @@ package config +import "time" + +// Birthday drives the uptime line on the about page. +var Birthday = time.Date(2008, time.April, 20, 0, 0, 0, 0, time.UTC) + +type KV struct { + Key, Value string +} + type About struct { Name string Handle string @@ -9,7 +18,8 @@ type About struct { ProfessionShort string Languages []string Interests []string - Tools []string + Hobbies []string + Setup []KV } func GetAbout() About { @@ -21,7 +31,13 @@ func GetAbout() About { Profession: "software engineer", ProfessionShort: "swe", Languages: []string{"zig", "rust", "c", "go", "typescript", "python"}, - Interests: []string{"low-level systems", "tui/cli tooling", "embedded", "unix philosophy"}, - Tools: []string{"zed (vim mode)", "opencode", "linux", "fish"}, + Interests: []string{"osdev", "firmware", "tui/cli tooling", "unix philosophy"}, + Hobbies: []string{"old-school audis", "rally", "pickup soccer"}, + Setup: []KV{ + {"os", "crux linux"}, + {"shell", "fish"}, + {"editor", "helix"}, + {"agent", "claude code"}, + }, } } diff --git a/internal/tui/about_ascii.go b/internal/tui/about_ascii.go @@ -11,23 +11,7 @@ import ( var aboutASCIIConvertMu sync.Mutex 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 + return m.aboutASCIICache[width] } func loadAboutASCII(width int) string { diff --git a/internal/tui/about_render.go b/internal/tui/about_render.go @@ -3,39 +3,22 @@ package tui import ( "strings" - "git.prime8.dev/moncheeta/prime8dev/internal/utils" tea "github.com/charmbracelet/bubbletea" ) +const aboutLogoWidth = 32 + 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 - } - + width := aboutLogoWidth if ascii, cached := m.aboutASCIICache[width]; cached { if strings.TrimSpace(ascii) != "" { return nil @@ -49,18 +32,6 @@ func (m *portfolioModel) maybeRenderAboutASCIICmd() tea.Cmd { 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{ @@ -78,11 +49,3 @@ func (m *portfolioModel) applyAboutASCIIRendered(msg aboutASCIIRenderedMsg) { } 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/content.go b/internal/tui/content.go @@ -4,32 +4,27 @@ import ( "fmt" "strconv" "strings" + "time" appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config" + "git.prime8.dev/moncheeta/prime8dev/internal/utils" + "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, + m.aboutFetch(), "", - renderPrompt(m.about.Handle, "cat info"), - m.infoNowTable(job), + renderPrompt(m.about.Handle, "cat bio"), + m.about.Bio, "", 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 hobbies/"), + renderTagList(m.about.Hobbies), "", - renderPrompt(m.about.Handle, "ls languages/"), - renderTagList(m.about.Languages), + renderPrompt(m.about.Handle, "") + rowStyle.Render("█"), } if appconfig.WakaTimeEnabled { parts = append(parts, "", renderPrompt(m.about.Handle, "wakatime --week"), m.aboutWeeklyStats()) @@ -38,6 +33,50 @@ func (m portfolioModel) aboutDetail() string { return strings.Join(parts, "\n") } +func (m portfolioModel) aboutFetch() string { + lines := m.aboutFetchLines() + logo := m.aboutASCIIFor(aboutLogoWidth) + if logo == "" { + return lines + } + if m.width < 100 { + return logo + "\n\n" + lines + } + return lipgloss.JoinHorizontal(lipgloss.Top, logo, " ", lines) +} + +func (m portfolioModel) aboutFetchLines() string { + title := m.about.Handle + "@prime8" + role := m.about.Profession + if m.currentJob != nil { + role = m.currentJob.Role + " @ " + m.currentJob.Company + } + + row := func(key, value string) string { + return fetchKeyStyle.Render(key) + ": " + value + } + + lines := []string{ + promptUserStyle.Render(m.about.Handle) + promptStyle.Render("@") + promptHostStyle.Render("prime8"), + dimStyle.Render(strings.Repeat("-", len(title))), + row("name", rowStyle.Render(m.about.Name)), + row("role", rowStyle.Render(role)), + row("location", rowStyle.Render(m.about.Location)), + row("uptime", rowStyle.Render(utils.Uptime(appconfig.Birthday, time.Now()))), + } + for _, kv := range m.about.Setup { + lines = append(lines, row(kv.Key, rowStyle.Render(kv.Value))) + } + lines = append(lines, + row("langs", coloredList(m.about.Languages)), + row("email", linkStyle.Render("damian@prime8.dev")), + row("git", linkStyle.Render("prime8.dev/projects")), + row("linkedin", linkStyle.Render("linkedin.com/in/damian-myrda")), + ) + + return strings.Join(lines, "\n") +} + func (m portfolioModel) aboutWeeklyStats() string { if m.wakaWeekly == nil || len(m.wakaWeekly.Days) == 0 { if m.wakaLoading || !m.wakaLoaded { @@ -88,67 +127,6 @@ func renderASCIIBar(percent int, width int) string { return strings.Repeat("#", filled) + strings.Repeat("-", width-filled) } -func (m portfolioModel) infoNowTable(job string) string { - left := [][2]string{ - {"location", m.about.Location}, - } - - 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." @@ -320,3 +298,15 @@ func (m portfolioModel) experiencePage() string { } return strings.Join(rows, "\n") } + +func coloredList(labels []string) string { + parts := make([]string, len(labels)) + for i, l := range labels { + style := rowStyle + if c := utils.TagColor(l); c != "" { + style = lipgloss.NewStyle().Foreground(lipgloss.Color(c)) + } + parts[i] = style.Render(l) + } + return strings.Join(parts, rowStyle.Render(", ")) +} diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go @@ -35,23 +35,6 @@ func fitRunes(s string, width int) string { 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 { @@ -60,12 +43,6 @@ func padRunesRight(s string, width int) string { return s + strings.Repeat(" ", width-len(r)) } -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 "" diff --git a/internal/tui/layout.go b/internal/tui/layout.go @@ -13,15 +13,6 @@ func (m *portfolioModel) syncViewport() { 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) diff --git a/internal/tui/styles.go b/internal/tui/styles.go @@ -50,6 +50,7 @@ var ( rowStyle = lipgloss.NewStyle().Foreground(colorFg) dimStyle = lipgloss.NewStyle().Foreground(colorDim) titleStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + fetchKeyStyle = lipgloss.NewStyle().Foreground(colorAccent) linkStyle = lipgloss.NewStyle().Foreground(colorBlue) infoStyle = lipgloss.NewStyle().Foreground(colorCyan) footerTimeStyle = lipgloss.NewStyle().Foreground(colorCyan).Background(colorFooterBg) diff --git a/internal/tui/types.go b/internal/tui/types.go @@ -26,14 +26,11 @@ var tabLabels = []string{"about", "experience", "projects"} 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 + user string + about appconfig.About + currentJob *appconfig.Experience + aboutASCIICache map[int]string + aboutASCIILoads map[int]bool projects []appconfig.Project experience []appconfig.Experience @@ -92,28 +89,25 @@ func newPortfolioModel(user string) portfolioModel { h.ShowAll = false m := portfolioModel{ - gitService: gitService, - 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, - 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, + gitService: gitService, + user: user, + about: about, + currentJob: appconfig.CurrentJob(exp), + aboutASCIICache: make(map[int]string), + aboutASCIILoads: make(map[int]bool), + projects: projects, + experience: exp, + 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), diff --git a/internal/tui/update.go b/internal/tui/update.go @@ -67,12 +67,6 @@ func (m portfolioModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -100,7 +94,6 @@ func (m *portfolioModel) refreshDataCmd() tea.Cmd { m.maybeRenderSelectedProjectMarkdownCmd(), m.maybeLoadWakaFooterCmd(), m.maybeRenderAboutASCIICmd(), - m.maybeRenderAboutNameCmd(), ) } diff --git a/internal/tui/view.go b/internal/tui/view.go @@ -165,8 +165,6 @@ func (m portfolioModel) renderHeader(width int) string { 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: @@ -174,32 +172,6 @@ func (m portfolioModel) renderBody(width int, height int) string { } } -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 diff --git a/internal/utils/ascii.go b/internal/utils/ascii.go @@ -1,42 +0,0 @@ -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/uptime.go b/internal/utils/uptime.go @@ -0,0 +1,21 @@ +package utils + +import ( + "fmt" + "time" +) + +// Uptime renders time since birth in uptime(1) style, e.g. "18y 183d". +func Uptime(birth, now time.Time) string { + if now.Before(birth) { + return "0d" + } + years := now.Year() - birth.Year() + anniv := birth.AddDate(years, 0, 0) + if anniv.After(now) { + years-- + anniv = birth.AddDate(years, 0, 0) + } + days := int(now.Sub(anniv).Hours() / 24) + return fmt.Sprintf("%dy %dd", years, days) +} diff --git a/internal/web/renderer.go b/internal/web/renderer.go @@ -7,6 +7,7 @@ import ( "io/fs" "path" "strings" + "time" "git.prime8.dev/moncheeta/prime8dev/internal/config" "git.prime8.dev/moncheeta/prime8dev/internal/external" @@ -22,7 +23,7 @@ type Data struct { ActiveTab string ContentTemplate string About config.About - AboutNameASCII string + Uptime string Experience []config.Experience SelectedExperienceID string CurrentJob *config.Experience @@ -57,6 +58,7 @@ func New() (*Renderer, error) { "tagClass": utils.TagClass, "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) }, "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) }, + "inc": func(i int) int { return i + 1 }, "techMeta": utils.TechMeta, "formatMonthYear": utils.FormatMonthYear, "formatEndDate": utils.FormatEndDate, @@ -106,7 +108,7 @@ func BaseData(title, activeTab string, about config.About) Data { ActiveTab: activeTab, ContentTemplate: activeTab, About: about, - AboutNameASCII: utils.AboutNameASCII(about.Name), + Uptime: utils.Uptime(config.Birthday, time.Now()), Tabs: DefaultTabs(), SiteURL: config.SiteURL, } diff --git a/internal/web/scripts/modules/about.js b/internal/web/scripts/modules/about.js @@ -3,8 +3,6 @@ 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; @@ -62,51 +60,6 @@ export function initAbout() { }); } -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 = ""; @@ -182,3 +135,4 @@ function asciiBarSegments(percent, width) { empty: "-".repeat(width - filled), }; } + diff --git a/internal/web/styles/about.css b/internal/web/styles/about.css @@ -1,109 +1,13 @@ .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); @@ -130,48 +34,6 @@ 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; @@ -179,44 +41,63 @@ 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; + margin: 0; } -.info-columns { +.tags { display: flex; - gap: 30px; + flex-wrap: wrap; + gap: 6px; } -.info-column { - flex: 1; +/* fastfetch */ +.fetch { + display: flex; + align-items: flex-start; + gap: 24px; + margin-top: 6px; } -.info-row { - display: flex; +.fetch-logo { + width: 200px; + aspect-ratio: 1; + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--border); + filter: saturate(0.85); +} + +.fetch-info { + min-width: 0; font-size: var(--fs-14); - margin-bottom: 2px; + line-height: 1.5; } -.info-row .label { +.fetch-rule { color: var(--fg-dim); - width: 100px; - flex-shrink: 0; } -.tags { - display: flex; - flex-wrap: wrap; - gap: 6px; +.fetch-row { + overflow-wrap: anywhere; +} + +.fetch-key { + color: var(--accent); +} + +.fetch-row a { + color: var(--light-blue); + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 3px; +} + +.fetch-row a:hover { + text-decoration-style: solid; } .weekly-stats { @@ -315,50 +196,30 @@ white-space: nowrap; } -@media (max-width: 768px) { +@media (max-width: 700px) { .about-layout { - flex-direction: column; + padding: 16px; } - .about-left { - flex-direction: row; - width: 100%; - border-right: none; - border-bottom: 1px solid var(--border); + .fetch { + flex-direction: column; + gap: 12px; } - .profile-image { + .fetch-logo { 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; - } +/* trailing prompt */ +.shell-cursor { + /* inline nbsp with a background = exactly one monospace cell, sits on the text baseline */ + display: inline; + background: var(--fg); + animation: cursorBlink 1.1s steps(1) infinite; +} +@keyframes cursorBlink { 50% { opacity: 0; } } - .info-columns { - flex-direction: column; - gap: 12px; - } +@media (prefers-reduced-motion: reduce) { + .shell-cursor { animation: none !important; } } diff --git a/internal/web/templates/partials/about.gohtml b/internal/web/templates/partials/about.gohtml @@ -1,76 +1,59 @@ {{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" 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 prime8.dev</code></pre> - <button type="button" class="about-sidebar-copy-btn" data-ssh-copy>copy</button> + <div class="section"> + <div class="fetch"> + <img class="fetch-logo" src="/static/me.jpg" alt="{{.About.Name}} - {{.About.Profession}}" loading="eager"> + <div class="fetch-info"> + <div class="fetch-title"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span></div> + <div class="fetch-rule" aria-hidden="true">----------------</div> + <div class="fetch-row"><span class="fetch-key">name</span>: {{.About.Name}}</div> + <div class="fetch-row"><span class="fetch-key">role</span>: {{with .CurrentJob}}{{.Role}} @ <a href="/experience/{{.ID}}" hx-get="/experience/{{.ID}}" hx-target="#content" hx-push-url="true">{{.Company}}</a>{{else}}{{.About.Profession}}{{end}}</div> + <div class="fetch-row"><span class="fetch-key">location</span>: {{.About.Location}}</div> + <div class="fetch-row"><span class="fetch-key">uptime</span>: {{.Uptime}}</div> + {{range .About.Setup}}<div class="fetch-row"><span class="fetch-key">{{.Key}}</span>: {{.Value}}</div> + {{end}} + <div class="fetch-row"><span class="fetch-key">langs</span>: {{range $i, $l := .About.Languages}}{{if $i}}, {{end}}<span style="color:{{tagColor $l}}">{{$l}}</span>{{end}}</div> + <div class="fetch-row"><span class="fetch-key">email</span>: <a href="mailto:damian@prime8.dev">damian@prime8.dev</a></div> + <div class="fetch-row"><span class="fetch-key">git</span>: <a href="/projects">prime8.dev/projects</a></div> + <div class="fetch-row"><span class="fetch-key">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> - <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> - {{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">cat bio</span></div> + <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">ls interests/</span></div> - <div class="tags"> - {{range .About.Interests}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}} - </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> + {{if .About.Hobbies}} + <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 hobbies/</span></div> + <div class="tags"> + {{range .About.Hobbies}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}} </div> + </div> + {{end}} - <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"> + <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-cursor" aria-hidden="true">&nbsp;</span></div> + </div> - {{if wakaTimeEnabled}} - <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> + {{if wakaTimeEnabled}} + <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> - {{end}} </div> + {{end}} </div> </section>