prime8dev

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

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 }