content.go (6532B)
1 package tui 2 3 import ( 4 "fmt" 5 "strings" 6 "time" 7 8 appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config" 9 "git.prime8.dev/moncheeta/prime8dev/internal/utils" 10 "github.com/charmbracelet/lipgloss" 11 ) 12 13 func (m portfolioModel) aboutDetail() string { 14 shellW, _ := m.detailSize() 15 if m.shell.cleared { 16 return m.shellView(shellW) 17 } 18 parts := []string{ 19 m.aboutFetch(), 20 "", 21 renderPrompt(m.user, "cat bio"), 22 m.about.Bio, 23 "", 24 renderPrompt(m.user, "ls interests/"), 25 renderTagList(m.about.Interests), 26 "", 27 renderPrompt(m.user, "ls hobbies/"), 28 renderTagList(m.about.Hobbies), 29 "", 30 m.shellView(shellW), 31 } 32 33 return strings.Join(parts, "\n") 34 } 35 36 func (m portfolioModel) aboutFetch() string { 37 lines := m.aboutFetchLines() 38 logo := m.aboutASCIIFor(aboutLogoWidth) 39 if logo == "" { 40 return lines 41 } 42 logo = revealedLogo(logo, m.aboutLogoRows) 43 if m.width < 100 { 44 return logo + "\n\n" + lines 45 } 46 return lipgloss.JoinHorizontal(lipgloss.Top, logo, " ", lines) 47 } 48 49 func (m portfolioModel) aboutFetchLines() string { 50 title := m.about.Handle + "@prime8" 51 role := m.about.Profession 52 if m.currentJob != nil { 53 role = m.currentJob.Role + " @ " + m.currentJob.Company 54 } 55 56 row := func(key, value string) string { 57 return fetchKeyStyle.Render(key) + ": " + value 58 } 59 60 lines := []string{ 61 promptUserStyle.Render(m.about.Handle) + promptStyle.Render("@") + promptHostStyle.Render("prime8"), 62 dimStyle.Render(strings.Repeat("-", len(title))), 63 row("name", rowStyle.Render(m.about.Name)), 64 row("role", rowStyle.Render(role)), 65 row("location", rowStyle.Render(m.about.Location)), 66 row("uptime", rowStyle.Render(utils.Uptime(appconfig.Birthday, time.Now()))), 67 } 68 for _, kv := range m.about.Setup { 69 lines = append(lines, row(kv.Key, rowStyle.Render(kv.Value))) 70 } 71 lines = append(lines, 72 row("langs", coloredList(m.about.Languages)), 73 row("email", linkStyle.Render("damian@prime8.dev")), 74 row("git", linkStyle.Render("prime8.dev/projects")), 75 row("linkedin", linkStyle.Render("linkedin.com/in/damian-myrda")), 76 ) 77 78 return strings.Join(lines, "\n") 79 } 80 81 func (m portfolioModel) projectDetail() string { 82 if len(m.projects) == 0 { 83 return "No projects available." 84 } 85 p := m.projects[m.projectIndex] 86 87 if readme, ok := m.projectReadmes[p.ID]; ok && strings.TrimSpace(readme) != "" { 88 if rendered, ok := m.cachedProjectMarkdown(p.ID, m.markdownWidth()); ok && strings.TrimSpace(rendered) != "" { 89 return rendered 90 } 91 // syncViewport renders before this runs; never fall back to raw markdown 92 return m.projectReadmeFallback(p, infoStyle.Render("rendering README...")) 93 } 94 95 if m.projectReadmeLoads[p.ID] { 96 return m.projectReadmeFallback(p, infoStyle.Render("loading README...")) 97 } 98 99 if msg, ok := m.projectReadmeErrors[p.ID]; ok && msg != "" { 100 return m.projectReadmeFallback(p, dimStyle.Render("README unavailable: "+msg)) 101 } 102 103 return m.projectReadmeFallback(p, infoStyle.Render("README not loaded yet.")) 104 } 105 106 func (m portfolioModel) projectReadmeFallback(project appconfig.Project, status string) string { 107 headerMarkdown := replaceFirstH1WithSourceLink("", project.Name, project.URL) 108 header := strings.TrimSpace(renderMarkdown(headerMarkdown, m.projectMarkdownWidth())) 109 if header == "" { 110 header = titleStyle.Render(defaultProjectHeader(project.Name)) 111 } 112 113 parts := []string{header} 114 115 if tech := renderTagList(project.Tech); tech != "" { 116 parts = append(parts, tech) 117 } 118 119 if strings.TrimSpace(status) != "" { 120 parts = append(parts, status) 121 } 122 123 return strings.Join(parts, "\n\n") 124 } 125 126 func defaultProjectHeader(name string) string { 127 name = strings.TrimSpace(name) 128 if name == "" { 129 return "Project Source" 130 } 131 return name 132 } 133 134 func replaceFirstH1WithSourceLink(markdown string, projectName string, projectURL string) string { 135 if projectName == "" { 136 projectName = "Project Source" 137 } 138 139 body := removeFirstMarkdownH1(markdown) 140 141 header := "# " + projectName 142 if strings.TrimSpace(projectURL) != "" { 143 header = "# [" + projectName + "](" + projectURL + ")" 144 } 145 146 if strings.TrimSpace(body) == "" { 147 return header 148 } 149 150 return header + "\n\n" + body 151 } 152 153 func removeFirstMarkdownH1(markdown string) string { 154 lines := strings.Split(markdown, "\n") 155 removed := false 156 out := make([]string, 0, len(lines)) 157 158 for _, line := range lines { 159 if !removed && strings.HasPrefix(strings.TrimSpace(line), "# ") { 160 removed = true 161 continue 162 } 163 out = append(out, line) 164 } 165 166 return strings.TrimSpace(strings.Join(out, "\n")) 167 } 168 169 func (m portfolioModel) projectMarkdownWidth() int { 170 return m.markdownWidth() 171 } 172 173 func (m portfolioModel) markdownWidth() int { 174 w, _ := m.detailSize() 175 return max(w, 20) 176 } 177 178 func (m portfolioModel) experiencePage() string { 179 if len(m.experience) == 0 { 180 return "No experience available." 181 } 182 183 // Every line starts with a two-column gutter ("> " marks the selection), 184 // then the graph column: "*" on a commit, "|" under it down to the next 185 // commit. The root commit has nothing below it, like git log --graph. 186 rows := []string{renderPrompt(m.user, "git log --oneline --graph --decorate")} 187 for i, e := range m.experience { 188 decorator := "" 189 if e.Current { 190 decorator = " (HEAD -> now)" 191 } 192 gutter := " " 193 if i == m.experienceIndex { 194 gutter = "> " 195 } 196 edge := "|" 197 if i == len(m.experience)-1 { 198 edge = " " 199 } 200 body := dimStyle.Render(experienceGraphGutter + edge + " ") 201 label := fmt.Sprintf("%-20s %s%s", fitRunes(e.Company, 20), fitRunes(e.Role, 22), decorator) 202 rangeLabel := dateRangeLabel(e) 203 if i == m.experienceIndex { 204 rows = append(rows, selectedRowStyle.Render(gutter+"* "+label)) 205 rows = append(rows, body+selectedRowStyle.Render(rangeLabel)) 206 for _, d := range e.Description { 207 rows = append(rows, body+rowStyle.Render(d)) 208 } 209 if len(e.Tech) > 0 { 210 rows = append(rows, body+dimStyle.Render("tech: ")+renderTagList(e.Tech)) 211 } 212 if e.URL != "" { 213 rows = append(rows, body+dimStyle.Render("url: ")+linkStyle.Render(e.URL)) 214 } 215 } else { 216 rows = append(rows, rowStyle.Render(gutter+"* "+label)) 217 rows = append(rows, body+dimStyle.Render(rangeLabel)) 218 } 219 } 220 return strings.Join(rows, "\n") 221 } 222 223 // experienceGraphGutter is the selection gutter before the graph column. 224 const experienceGraphGutter = " " 225 226 func coloredList(labels []string) string { 227 parts := make([]string, len(labels)) 228 for i, l := range labels { 229 style := rowStyle 230 if c := utils.TagColor(l); c != "" { 231 style = lipgloss.NewStyle().Foreground(lipgloss.Color(c)) 232 } 233 parts[i] = style.Render(l) 234 } 235 return strings.Join(parts, rowStyle.Render(", ")) 236 }