commit 57ec89a62c5cd7e66c6fdb5e05a94ea71193aead
parent 26fedd4c19ccbff49a88731947c7cec6b475d777
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:
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"> </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>