content.go (12676B)
1 package tui 2 3 import ( 4 "fmt" 5 "strconv" 6 "strings" 7 8 appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config" 9 appcontent "git.prime8.dev/moncheeta/prime8dev/internal/content" 10 "github.com/charmbracelet/lipgloss" 11 ) 12 13 func (m portfolioModel) aboutDetail() string { 14 job := "building and learning" 15 if m.currentJob != nil { 16 job = fmt.Sprintf("%s @ %s", m.currentJob.Role, m.currentJob.Company) 17 } 18 19 parts := []string{ 20 renderPrompt(m.about.Handle, "whoami"), 21 m.aboutNameASCII, 22 m.about.Bio, 23 "", 24 renderPrompt(m.about.Handle, "cat info"), 25 m.infoNowTable(job), 26 "", 27 renderPrompt(m.about.Handle, "ls interests/"), 28 renderTagList(m.about.Interests), 29 "", 30 renderPrompt(m.about.Handle, "ls tools/"), 31 renderTagList(m.about.Tools), 32 "", 33 renderPrompt(m.about.Handle, "ls languages/"), 34 renderTagList(m.about.Languages), 35 "", 36 renderPrompt(m.about.Handle, "wakatime --week"), 37 m.aboutWeeklyStats(), 38 } 39 40 return strings.Join(parts, "\n") 41 } 42 43 func (m portfolioModel) aboutWeeklyStats() string { 44 if m.wakaWeekly == nil || len(m.wakaWeekly.Days) == 0 { 45 if m.wakaLoading || !m.wakaLoaded { 46 return dimStyle.Render("loading stats...") 47 } 48 return dimStyle.Render("stats unavailable") 49 } 50 51 barWidth := 24 52 lines := make([]string, 0, len(m.wakaWeekly.Days)+2) 53 lines = append(lines, rowStyle.Render("total: ")+infoStyle.Render(m.wakaWeekly.TotalText), "") 54 55 for _, day := range m.wakaWeekly.Days { 56 label := strings.ToLower(fitRunes(day.DayText, 3)) 57 label = padRunesRight(label, 3) 58 percent := clampBarPercent(day.BarWidthPercent) 59 bar := renderASCIIBar(percent, barWidth) 60 line := dimStyle.Render(label+" ") + rowStyle.Render("|"+bar+"|") + dimStyle.Render(" "+day.TimeText) 61 lines = append(lines, line) 62 } 63 64 return strings.Join(lines, "\n") 65 } 66 67 func clampBarPercent(percent string) int { 68 v := strings.TrimSpace(strings.TrimSuffix(percent, "%")) 69 n, err := strconv.Atoi(v) 70 if err != nil { 71 return 0 72 } 73 if n < 0 { 74 return 0 75 } 76 if n > 100 { 77 return 100 78 } 79 return n 80 } 81 82 func renderASCIIBar(percent int, width int) string { 83 filled := (percent * width) / 100 84 if filled < 0 { 85 filled = 0 86 } 87 if filled > width { 88 filled = width 89 } 90 return strings.Repeat("#", filled) + strings.Repeat("-", width-filled) 91 } 92 93 func (m portfolioModel) infoNowTable(job string) string { 94 left := [][2]string{ 95 {"location", m.about.Location}, 96 } 97 98 right := [][2]string{} 99 if m.currentJob != nil { 100 right = append(right, 101 [2]string{"company", m.currentJob.Company}, 102 [2]string{"position", m.currentJob.Role}, 103 ) 104 } else { 105 right = append(right, [2]string{"current", job}) 106 } 107 108 labelLW := 10 109 labelRW := 8 110 gap := 3 111 viewW, _ := m.detailSize() 112 available := max(viewW-2, 44) 113 valueBudget := available - (labelLW + 3) - gap - (labelRW + 3) 114 if valueBudget < 20 { 115 valueBudget = 20 116 } 117 valueLW := valueBudget / 2 118 valueRW := valueBudget - valueLW 119 120 rows := max(len(left), len(right)) 121 lines := make([]string, 0, rows) 122 for i := 0; i < rows; i++ { 123 lk, lv := "", "" 124 if i < len(left) { 125 lk = left[i][0] 126 lv = fitRunes(left[i][1], valueLW) 127 } 128 129 rk, rv := "", "" 130 if i < len(right) { 131 rk = right[i][0] 132 rv = fitRunes(right[i][1], valueRW) 133 } 134 135 line := renderInfoCell(lk, lv, labelLW, valueLW) 136 if rk != "" || rv != "" { 137 line += strings.Repeat(" ", gap) + renderInfoCell(rk, rv, labelRW, valueRW) 138 } 139 lines = append(lines, strings.TrimRight(line, " ")) 140 } 141 142 return strings.Join(lines, "\n") 143 } 144 145 func renderInfoCell(key string, value string, keyWidth int, valueWidth int) string { 146 key = fitRunes(key, keyWidth) 147 value = fitRunes(value, valueWidth) 148 keyPadding := max(keyWidth-len([]rune(key)), 0) 149 keyText := key + ":" + strings.Repeat(" ", keyPadding+1) 150 valueText := fmt.Sprintf("%-*s", valueWidth, value) 151 return dimStyle.Render(keyText) + valueText 152 } 153 154 func (m portfolioModel) projectDetail() string { 155 if len(m.projects) == 0 { 156 return "No projects available." 157 } 158 p := m.projects[m.projectIndex] 159 160 if readme, ok := m.projectReadmes[p.ID]; ok && strings.TrimSpace(readme) != "" { 161 width := m.markdownWidth() 162 if rendered, ok := m.cachedProjectMarkdown(p.ID, width); ok && strings.TrimSpace(rendered) != "" { 163 return rendered 164 } 165 166 patched := replaceFirstH1WithSourceLink(readme, p.Name, p.URL) 167 fallback := patched 168 if tech := renderTagList(p.Tech); tech != "" { 169 fallback = insertTechRowBelowMarkdownH1(fallback, tech) 170 } 171 172 return fallback + "\n\n" + dimStyle.Render("formatting markdown...") 173 } 174 175 if m.projectReadmeLoads[p.ID] { 176 return m.projectReadmeFallback(p, infoStyle.Render("loading README...")) 177 } 178 179 if msg, ok := m.projectReadmeErrors[p.ID]; ok && msg != "" { 180 return m.projectReadmeFallback(p, dimStyle.Render("README unavailable: "+msg)) 181 } 182 183 return m.projectReadmeFallback(p, infoStyle.Render("README not loaded yet.")) 184 } 185 186 func (m portfolioModel) projectReadmeFallback(project appconfig.Project, status string) string { 187 headerMarkdown := replaceFirstH1WithSourceLink("", project.Name, project.URL) 188 header := strings.TrimSpace(renderMarkdown(headerMarkdown, m.projectMarkdownWidth())) 189 if header == "" { 190 header = titleStyle.Render(defaultProjectHeader(project.Name)) 191 } 192 193 parts := []string{header} 194 195 if tech := renderTagList(project.Tech); tech != "" { 196 parts = append(parts, tech) 197 } 198 199 if strings.TrimSpace(status) != "" { 200 parts = append(parts, status) 201 } 202 203 return strings.Join(parts, "\n\n") 204 } 205 206 func defaultProjectHeader(name string) string { 207 name = strings.TrimSpace(name) 208 if name == "" { 209 return "Project Source" 210 } 211 return name 212 } 213 214 func insertTechRowBelowMarkdownH1(markdown string, techRow string) string { 215 if strings.TrimSpace(techRow) == "" { 216 return markdown 217 } 218 219 lines := strings.Split(markdown, "\n") 220 headIdx := -1 221 for i, line := range lines { 222 if strings.TrimSpace(line) != "" { 223 headIdx = i 224 break 225 } 226 } 227 if headIdx == -1 { 228 return techRow 229 } 230 231 if !strings.HasPrefix(strings.TrimSpace(lines[headIdx]), "# ") { 232 return techRow + "\n\n" + markdown 233 } 234 235 bodyStart := headIdx + 1 236 for bodyStart < len(lines) && strings.TrimSpace(lines[bodyStart]) == "" { 237 bodyStart++ 238 } 239 240 header := strings.Join(lines[:headIdx+1], "\n") 241 if bodyStart >= len(lines) { 242 return header + "\n\n" + techRow 243 } 244 245 body := strings.Join(lines[bodyStart:], "\n") 246 return header + "\n\n" + techRow + "\n\n" + body 247 } 248 249 func replaceFirstH1WithSourceLink(markdown string, projectName string, projectURL string) string { 250 if projectName == "" { 251 projectName = "Project Source" 252 } 253 254 body := removeFirstMarkdownH1(markdown) 255 256 header := "# " + projectName 257 if strings.TrimSpace(projectURL) != "" { 258 header = "# [" + projectName + "](" + projectURL + ")" 259 } 260 261 if strings.TrimSpace(body) == "" { 262 return header 263 } 264 265 return header + "\n\n" + body 266 } 267 268 func removeFirstMarkdownH1(markdown string) string { 269 lines := strings.Split(markdown, "\n") 270 removed := false 271 out := make([]string, 0, len(lines)) 272 273 for _, line := range lines { 274 if !removed && strings.HasPrefix(strings.TrimSpace(line), "# ") { 275 removed = true 276 continue 277 } 278 out = append(out, line) 279 } 280 281 return strings.TrimSpace(strings.Join(out, "\n")) 282 } 283 284 func (m portfolioModel) projectMarkdownWidth() int { 285 return m.markdownWidth() 286 } 287 288 func (m portfolioModel) markdownWidth() int { 289 w, _ := m.detailSize() 290 return max(w, 20) 291 } 292 293 func (m portfolioModel) experiencePage() string { 294 if len(m.experience) == 0 { 295 return "No experience available." 296 } 297 298 rows := []string{renderPrompt(m.about.Handle, "git log --oneline --graph --decorate")} 299 for i, e := range m.experience { 300 decorator := "" 301 if e.Current { 302 decorator = " (HEAD -> now)" 303 } 304 label := fmt.Sprintf("* %s %-20s %s%s", utilsYearLabel(e), fitRunes(e.Company, 20), fitRunes(e.Role, 22), decorator) 305 rangeLine := " " + dateRangeLabel(e) 306 if i == m.experienceIndex { 307 rows = append(rows, selectedRowStyle.Render("> "+label)) 308 rows = append(rows, selectedRowStyle.Render(rangeLine)) 309 for _, d := range e.Description { 310 rows = append(rows, rowStyle.Render(" | "+d)) 311 } 312 if len(e.Tech) > 0 { 313 rows = append(rows, rowStyle.Render(" | ")+dimStyle.Render("tech: ")+renderTagList(e.Tech)) 314 } 315 if e.URL != "" { 316 rows = append(rows, rowStyle.Render(" | ")+dimStyle.Render("url: ")+linkStyle.Render(e.URL)) 317 } 318 } else { 319 rows = append(rows, rowStyle.Render(" "+label)) 320 rows = append(rows, dimStyle.Render(rangeLine)) 321 } 322 } 323 return strings.Join(rows, "\n") 324 } 325 326 func (m portfolioModel) blogDetail() string { 327 if len(m.blogs) == 0 { 328 return "~/blog is empty" 329 } 330 b := m.blogs[m.blogIndex] 331 parts := []string{renderPrompt(m.about.Handle, "cat ~/blog/"+b.ID+".md")} 332 parts = append(parts, renderBlogFrontmatter(b)...) 333 if m.blogOpen { 334 width := m.markdownWidth() 335 if rendered, ok := m.cachedBlogMarkdown(b.ID, width); ok && rendered != "" { 336 parts = append(parts, "", rendered) 337 } else { 338 fallback := normalizeBlogMarkdown(b.Markdown, b.Description) 339 if strings.TrimSpace(fallback) != "" { 340 parts = append(parts, "", fallback, "", dimStyle.Render("formatting markdown...")) 341 } else { 342 parts = append(parts, "", infoStyle.Render("rendering markdown...")) 343 } 344 } 345 } 346 return strings.Join(parts, "\n") 347 } 348 349 func renderBlogFrontmatter(b appcontent.Blog) []string { 350 lines := []string{ 351 renderBlogMetaLine("title", b.Title, titleStyle), 352 renderBlogMetaLine("description", b.Description, rowStyle), 353 renderBlogMetaLine("date", b.Date.Format("2006-01-02"), infoStyle), 354 } 355 356 tags := strings.TrimSpace(renderTagList(b.Tags)) 357 if tags != "" { 358 lines = append(lines, dimStyle.Render(padRunesRight("tags:", 12)+" ")+tags) 359 } 360 361 return lines 362 } 363 364 func renderBlogMetaLine(label string, value string, valueStyle lipgloss.Style) string { 365 key := dimStyle.Render(padRunesRight(label+":", 12) + " ") 366 return key + valueStyle.Render(value) 367 } 368 369 func normalizeBlogMarkdown(markdown string, description string) string { 370 lines := strings.Split(markdown, "\n") 371 for i := range lines { 372 trimmed := strings.TrimSpace(lines[i]) 373 if trimmed == "" { 374 continue 375 } 376 377 if strings.EqualFold(trimmed, strings.TrimSpace(description)) { 378 lines[i] = "# " + trimmed 379 } 380 break 381 } 382 383 return strings.Join(lines, "\n") 384 } 385 386 func (m portfolioModel) blogMarkdownWidth() int { 387 return m.markdownWidth() 388 } 389 390 func (m portfolioModel) blogPage() string { 391 if len(m.blogs) == 0 { 392 return "~/blog is empty" 393 } 394 395 if m.blogOpen { 396 return m.blogDetail() 397 } 398 399 modeW, dateW, nameW, descW := m.blogTableWidths() 400 markerW := 2 401 header := blogHeaderLine(markerW, modeW, dateW, nameW, descW) 402 403 rows := []string{ 404 renderPrompt(m.about.Handle, "ls -l ~/blog"), 405 header, 406 } 407 for i, b := range m.blogs { 408 rows = append(rows, blogDataLine(i == m.blogIndex, b, markerW, modeW, dateW, nameW, descW)) 409 } 410 411 return strings.Join(rows, "\n") 412 } 413 414 func (m portfolioModel) blogTableWidths() (int, int, int, int) { 415 viewW, _ := m.detailSize() 416 available := max(viewW-5, 24) 417 418 modeW := 10 419 dateW := 10 420 remaining := available - modeW - dateW - 3 421 if remaining < 14 { 422 nameW := max(4, remaining/2) 423 descW := max(4, remaining-nameW) 424 return modeW, dateW, nameW, descW 425 } 426 427 nameW := min(40, max(12, remaining/2)) 428 descW := max(8, remaining-nameW) 429 430 maxName := 0 431 maxDesc := 0 432 for _, b := range m.blogs { 433 nameLen := len([]rune(b.ID + ".md")) 434 descLen := len([]rune(b.Description)) 435 if nameLen > maxName { 436 maxName = nameLen 437 } 438 if descLen > maxDesc { 439 maxDesc = descLen 440 } 441 } 442 443 if maxName > 0 && maxName < nameW { 444 spare := nameW - maxName 445 nameW = maxName 446 descW += spare 447 } 448 if maxDesc > 0 && maxDesc < descW { 449 descW = maxDesc 450 } 451 452 return modeW, dateW, max(nameW, 8), max(descW, 8) 453 } 454 455 func blogHeaderLine(markerW int, modeW int, dateW int, nameW int, descW int) string { 456 marker := lipgloss.NewStyle().Width(markerW).Render("") 457 mode := dimStyle.Width(modeW).Render("mode") 458 date := dimStyle.Width(dateW).Render("date") 459 name := dimStyle.Width(nameW).Render("name") 460 desc := dimStyle.Width(descW).Render("desc") 461 return lipgloss.JoinHorizontal(lipgloss.Top, marker, mode, " ", date, " ", name, " ", desc) 462 } 463 464 func blogDataLine(selected bool, b appcontent.Blog, markerW int, modeW int, dateW int, nameW int, descW int) string { 465 marker := "" 466 if selected { 467 marker = ">" 468 } 469 470 markerCol := rowStyle.Width(markerW).Render(marker) 471 if selected { 472 markerCol = selectedRowStyle.Width(markerW).Render(marker) 473 } 474 modeCol := blogModeStyle.Width(modeW).Render("-rw-r--r--") 475 dateCol := dimStyle.Width(dateW).Render(b.Date.Format("2006-01-02")) 476 nameCol := blogNameStyle.Width(nameW).Render(fitRunes(b.ID+".md", nameW)) 477 descCol := dimStyle.Width(descW).Render(fitRunes(b.Description, max(descW, 4))) 478 479 return lipgloss.JoinHorizontal(lipgloss.Top, markerCol, modeCol, " ", dateCol, " ", nameCol, " ", descCol) 480 }