Merge pull request #75 from LazyBachelor/LPM-140

LPM-140 The Sprinting Update
Adds sprint support across the web UI (board + issue detail), TUI kanban, CLI/REPL, and the Beads-backed storage layer.

Changes:

Introduces sprint entities in storage and exposes sprint operations via IssueService.
Updates web board to support backlog vs. sprint columns, sprint selection, and sprint creation routes.
Updates TUI kanban to show backlog + sprint columns and adds sprint selection/creation actions.
This commit is contained in:
Robin Olsen
2026-03-23 11:26:04 -07:00
committed by GitHub
parent 06404708ba
commit d900a5fcd3
33 changed files with 2378 additions and 771 deletions

View File

@@ -7,10 +7,14 @@ import (
)
type BoardViewProps struct {
Issues []*models.Issue
BaseURL string
QueryParam string
EmptyText string
Issues []*models.Issue
BacklogIssues []*models.Issue
SprintIssues []*models.Issue
CurrentSprint int
Sprints []int
BaseURL string
QueryParam string
EmptyText string
}
templ BoardView(props BoardViewProps) {
@@ -22,13 +26,6 @@ templ BoardView(props BoardViewProps) {
templ BoardViewContent(props BoardViewProps) {
<div class="flex flex-col h-full">
<div class="flex flex-col border-b border-base-200">
<div class="flex p-2 border-b border-base-200 items-center gap-2">
@components.SearchForm(components.SearchFormProps{
RootURL: props.BaseURL,
SearchQuery: props.QueryParam,
Target: "#board-container",
})
</div>
<div class="flex p-2 justify-between items-center gap-2">
<div class="flex gap-2">
<div class="join">
@@ -50,53 +47,86 @@ templ BoardViewContent(props BoardViewProps) {
New Issue
</button>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">Sprint:</span>
<select
class="select select-sm select-bordered"
hx-get="/board/sprint"
hx-target="#board-columns-container"
hx-swap="innerHTML"
name="sprint"
>
if len(props.Sprints) == 0 {
<option value="0">No Sprints</option>
}
for _, sprintNum := range props.Sprints {
if sprintNum == props.CurrentSprint {
<option value={ fmt.Sprintf("%d", sprintNum) } selected>{ fmt.Sprintf("Sprint %d", sprintNum) }</option>
} else {
<option value={ fmt.Sprintf("%d", sprintNum) }>{ fmt.Sprintf("Sprint %d", sprintNum) }</option>
}
}
</select>
<button
class="btn btn-sm btn-outline"
hx-post="/board/sprint/new"
hx-target="#board-columns-container"
hx-swap="innerHTML"
>
New Sprint
</button>
</div>
</div>
</div>
<div id="board-container" class="flex-1 overflow-x-auto p-4">
@BoardColumns(props.Issues)
<div id="board-columns-container" class="flex-1 overflow-x-auto p-4">
@BoardColumns(props)
</div>
</div>
}
templ BoardColumns(issues []*models.Issue) {
templ BoardColumns(props BoardViewProps) {
{{
openIssues := []*models.Issue{}
backlogIssues := props.BacklogIssues
todoIssues := []*models.Issue{}
inProgressIssues := []*models.Issue{}
blockedIssues := []*models.Issue{}
readyToSprintIssues := []*models.Issue{}
closedIssues := []*models.Issue{}
doneIssues := []*models.Issue{}
for _, issue := range issues {
for _, issue := range props.SprintIssues {
switch issue.Status {
case "open":
openIssues = append(openIssues, issue)
todoIssues = append(todoIssues, issue)
case "in_progress":
inProgressIssues = append(inProgressIssues, issue)
case "blocked":
blockedIssues = append(blockedIssues, issue)
case "ready_to_sprint":
readyToSprintIssues = append(readyToSprintIssues, issue)
case "closed":
closedIssues = append(closedIssues, issue)
doneIssues = append(doneIssues, issue)
}
}
sprintName := "No Sprint"
if props.CurrentSprint > 0 {
sprintName = fmt.Sprintf("Sprint %d", props.CurrentSprint)
}
}}
<div class="flex gap-4 h-full" id="board-columns">
@BoardColumn("Open", "open", openIssues, "badge-info")
@BoardColumn("In Progress", "in_progress", inProgressIssues, "badge-warning")
@BoardColumn("Blocked", "blocked", blockedIssues, "badge-error")
@BoardColumn("Ready to sprint", "ready_to_sprint", readyToSprintIssues, "badge-primary")
@BoardColumn("Closed", "closed", closedIssues, "badge-success")
@BoardColumn("Backlog", "backlog", backlogIssues, "badge-ghost", "")
@BoardColumn(fmt.Sprintf("%s - To Do", sprintName), "todo", todoIssues, "badge-info", sprintName)
@BoardColumn(fmt.Sprintf("%s - In Progress", sprintName), "in_progress", inProgressIssues, "badge-warning", sprintName)
@BoardColumn(fmt.Sprintf("%s - Blocked", sprintName), "blocked", blockedIssues, "badge-error", sprintName)
@BoardColumn(fmt.Sprintf("%s - Done", sprintName), "done", doneIssues, "badge-success", sprintName)
</div>
}
templ BoardColumn(title string, status string, issues []*models.Issue, badgeClass string) {
<div class="flex-1 min-w-80 flex flex-col board-column" data-status={ status }>
templ BoardColumn(title string, status string, issues []*models.Issue, badgeClass string, sprintName string) {
<div class="flex-1 min-w-80 flex flex-col board-column" data-status={ status } data-sprint={ sprintName }>
<div class="flex items-center gap-2 mb-4">
<h2 class="text-lg font-semibold">{ title }</h2>
<span class={ "badge", badgeClass }>{ fmt.Sprintf("%d", len(issues)) }</span>
</div>
<div class="flex-1 space-y-2 bg-base-200 rounded-lg p-2 min-h-[120px] column-drop-zone" data-status={ status }>
<div class="flex-1 space-y-2 bg-base-200 rounded-lg p-2 min-h-30 column-drop-zone" data-status={ status } data-sprint={ sprintName }>
if len(issues) == 0 {
<div class="text-center text-base-content/50 py-4 empty-drop-hint">Drop issues here</div>
}
@@ -116,19 +146,16 @@ templ BoardCard(issue *models.Issue) {
<div class="card-body p-4">
<div class="flex items-start justify-between gap-2">
<h3 class="card-title text-sm font-medium line-clamp-2 flex-1">{ issue.Title }</h3>
<div class="flex gap-1 flex-shrink-0">
<button
class="btn btn-ghost btn-xs"
hx-get={ "/issues/" + issue.ID + "?from=board" }
hx-target="main"
hx-swap="innerHTML"
hx-push-url="true"
title="View details"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</button>
<div class="flex gap-1 shrink-0">
<a
class="btn btn-ghost btn-xs"
href={ "/issues/" + issue.ID }
title="View details"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</a>
<button
class="btn btn-ghost btn-xs"
hx-get={ "/issues/" + issue.ID + "/edit?from=board" }

View File

@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
// templ: version: v0.3.977
package routes
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -15,10 +15,14 @@ import (
)
type BoardViewProps struct {
Issues []*models.Issue
BaseURL string
QueryParam string
EmptyText string
Issues []*models.Issue
BacklogIssues []*models.Issue
SprintIssues []*models.Issue
CurrentSprint int
Sprints []int
BaseURL string
QueryParam string
EmptyText string
}
func BoardView(props BoardViewProps) templ.Component {
@@ -89,27 +93,90 @@ func BoardViewContent(props BoardViewProps) templ.Component {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex flex-col h-full\"><div class=\"flex flex-col border-b border-base-200\"><div class=\"flex p-2 border-b border-base-200 items-center gap-2\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex flex-col h-full\"><div class=\"flex flex-col border-b border-base-200\"><div class=\"flex p-2 justify-between items-center gap-2\"><div class=\"flex gap-2\"><div class=\"join\"><button class=\"btn btn-sm join-item\" hx-get=\"/\" hx-target=\"main\" hx-swap=\"innerHTML\" hx-push-url=\"true\">List</button> <button class=\"btn btn-sm join-item btn-active btn-primary\">Board</button></div><button class=\"btn btn-primary btn-sm\" hx-get=\"/issues/create?from=board\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\">New Issue</button></div><div class=\"flex items-center gap-2\"><span class=\"text-sm font-medium\">Sprint:</span> <select class=\"select select-sm select-bordered\" hx-get=\"/board/sprint\" hx-target=\"#board-columns-container\" hx-swap=\"innerHTML\" name=\"sprint\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = components.SearchForm(components.SearchFormProps{
RootURL: props.BaseURL,
SearchQuery: props.QueryParam,
Target: "#board-container",
}).Render(ctx, templ_7745c5c3_Buffer)
if len(props.Sprints) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"0\">No Sprints</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, sprintNum := range props.Sprints {
if sprintNum == props.CurrentSprint {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", sprintNum))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 64, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" selected>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Sprint %d", sprintNum))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 64, Col: 101}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", sprintNum))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 66, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Sprint %d", sprintNum))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 66, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</select> <button class=\"btn btn-sm btn-outline\" hx-post=\"/board/sprint/new\" hx-target=\"#board-columns-container\" hx-swap=\"innerHTML\">New Sprint</button></div></div></div><div id=\"board-columns-container\" class=\"flex-1 overflow-x-auto p-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><div class=\"flex p-2 justify-between items-center gap-2\"><div class=\"flex gap-2\"><div class=\"join\"><button class=\"btn btn-sm join-item\" hx-get=\"/\" hx-target=\"main\" hx-swap=\"innerHTML\" hx-push-url=\"true\">List</button> <button class=\"btn btn-sm join-item btn-active btn-primary\">Board</button></div><button class=\"btn btn-primary btn-sm\" hx-get=\"/issues/create?from=board\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\">New Issue</button></div></div></div><div id=\"board-container\" class=\"flex-1 overflow-x-auto p-4\">")
templ_7745c5c3_Err = BoardColumns(props).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = BoardColumns(props.Issues).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -117,7 +184,7 @@ func BoardViewContent(props BoardViewProps) templ.Component {
})
}
func BoardColumns(issues []*models.Issue) templ.Component {
func BoardColumns(props BoardViewProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -133,56 +200,60 @@ func BoardColumns(issues []*models.Issue) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
openIssues := []*models.Issue{}
backlogIssues := props.BacklogIssues
todoIssues := []*models.Issue{}
inProgressIssues := []*models.Issue{}
blockedIssues := []*models.Issue{}
readyToSprintIssues := []*models.Issue{}
closedIssues := []*models.Issue{}
doneIssues := []*models.Issue{}
for _, issue := range issues {
for _, issue := range props.SprintIssues {
switch issue.Status {
case "open":
openIssues = append(openIssues, issue)
todoIssues = append(todoIssues, issue)
case "in_progress":
inProgressIssues = append(inProgressIssues, issue)
case "blocked":
blockedIssues = append(blockedIssues, issue)
case "ready_to_sprint":
readyToSprintIssues = append(readyToSprintIssues, issue)
case "closed":
closedIssues = append(closedIssues, issue)
doneIssues = append(doneIssues, issue)
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"flex gap-4 h-full\" id=\"board-columns\">")
sprintName := "No Sprint"
if props.CurrentSprint > 0 {
sprintName = fmt.Sprintf("Sprint %d", props.CurrentSprint)
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"flex gap-4 h-full\" id=\"board-columns\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = BoardColumn("Open", "open", openIssues, "badge-info").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = BoardColumn("Backlog", "backlog", backlogIssues, "badge-ghost", "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = BoardColumn("In Progress", "in_progress", inProgressIssues, "badge-warning").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = BoardColumn(fmt.Sprintf("%s - To Do", sprintName), "todo", todoIssues, "badge-info", sprintName).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = BoardColumn("Blocked", "blocked", blockedIssues, "badge-error").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = BoardColumn(fmt.Sprintf("%s - In Progress", sprintName), "in_progress", inProgressIssues, "badge-warning", sprintName).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = BoardColumn("Ready to sprint", "ready_to_sprint", readyToSprintIssues, "badge-primary").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = BoardColumn(fmt.Sprintf("%s - Blocked", sprintName), "blocked", blockedIssues, "badge-error", sprintName).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = BoardColumn("Closed", "closed", closedIssues, "badge-success").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = BoardColumn(fmt.Sprintf("%s - Done", sprintName), "done", doneIssues, "badge-success", sprintName).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -190,7 +261,7 @@ func BoardColumns(issues []*models.Issue) templ.Component {
})
}
func BoardColumn(title string, status string, issues []*models.Issue, badgeClass string) templ.Component {
func BoardColumn(title string, status string, issues []*models.Issue, badgeClass string, sprintName string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -206,91 +277,117 @@ func BoardColumn(title string, status string, issues []*models.Issue, badgeClass
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"flex-1 min-w-80 flex flex-col board-column\" data-status=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 94, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><div class=\"flex items-center gap-2 mb-4\"><h2 class=\"text-lg font-semibold\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 96, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 = []any{"badge", badgeClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"flex-1 min-w-80 flex flex-col board-column\" data-status=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(issues)))
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 97, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 124, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span></div><div class=\"flex-1 space-y-2 bg-base-200 rounded-lg p-2 min-h-[120px] column-drop-zone\" data-status=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-sprint=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(status)
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(sprintName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 99, Col: 110}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 124, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><div class=\"flex items-center gap-2 mb-4\"><h2 class=\"text-lg font-semibold\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 126, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 = []any{"badge", badgeClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var13).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(issues)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 127, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</span></div><div class=\"flex-1 space-y-2 bg-base-200 rounded-lg p-2 min-h-30 column-drop-zone\" data-status=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 129, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-sprint=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sprintName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 129, Col: 132}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(issues) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"text-center text-base-content/50 py-4 empty-drop-hint\">Drop issues here</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"text-center text-base-content/50 py-4 empty-drop-hint\">Drop issues here</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -301,7 +398,7 @@ func BoardColumn(title string, status string, issues []*models.Issue, badgeClass
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -325,77 +422,77 @@ func BoardCard(issue *models.Issue) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
if templ_7745c5c3_Var12 == nil {
templ_7745c5c3_Var12 = templ.NopComponent
templ_7745c5c3_Var18 := templ.GetChildren(ctx)
if templ_7745c5c3_Var18 == nil {
templ_7745c5c3_Var18 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"card bg-base-100 shadow-sm hover:shadow-md transition-shadow cursor-grab active:cursor-grabbing board-card\" draggable=\"true\" data-issue-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"card bg-base-100 shadow-sm hover:shadow-md transition-shadow cursor-grab active:cursor-grabbing board-card\" draggable=\"true\" data-issue-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID)
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 114, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 144, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"><div class=\"card-body p-4\"><div class=\"flex items-start justify-between gap-2\"><h3 class=\"card-title text-sm font-medium line-clamp-2 flex-1\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\"><div class=\"card-body p-4\"><div class=\"flex items-start justify-between gap-2\"><h3 class=\"card-title text-sm font-medium line-clamp-2 flex-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 118, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 148, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><div class=\"flex gap-1 flex-shrink-0\"><button class=\"btn btn-ghost btn-xs\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</h3><div class=\"flex gap-1 shrink-0\"><a class=\"btn btn-ghost btn-xs\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID + "?from=board")
var templ_7745c5c3_Var21 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs("/issues/" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 122, Col: 52}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 152, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-target=\"main\" hx-swap=\"innerHTML\" hx-push-url=\"true\" title=\"View details\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"></path></svg></button> <button class=\"btn btn-ghost btn-xs\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" title=\"View details\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"></path></svg></a> <button class=\"btn btn-ghost btn-xs\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID + "/edit?from=board")
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID + "/edit?from=board")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 134, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 161, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" title=\"Edit issue\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\"></path></svg></button></div></div><div class=\"flex items-center gap-2 mt-2\"><span class=\"badge badge-sm badge-ghost font-mono\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" title=\"Edit issue\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\"></path></svg></button></div></div><div class=\"flex items-center gap-2 mt-2\"><span class=\"badge badge-sm badge-ghost font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID)
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 146, Col: 65}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 173, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -407,30 +504,30 @@ func BoardCard(issue *models.Issue) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issue.Description != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<p class=\"text-sm text-base-content/70 line-clamp-2 mt-2\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<p class=\"text-sm text-base-content/70 line-clamp-2 mt-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Description)
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 151, Col: 81}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 178, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@@ -1,6 +1,7 @@
package routes
import (
"fmt"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/pkg/web/components"
"github.com/LazyBachelor/LazyPM/pkg/web/components/base"
@@ -21,17 +22,14 @@ templ Dashboard(props DashboardProps) {
}
templ DashboardContent(props DashboardProps) {
<div class="flex flex-col h-full">
{{
issuesJSON, _ := templ.JSONString(props.Issues)
xData := fmt.Sprintf(`{ selectedId: '%s', issues: %s }`, props.SelectedIssue.ID, issuesJSON)
}}
<div class="flex flex-col h-full" x-data={ xData }>
<div class="flex flex-col border-b border-base-200">
<div class="flex p-2 border-b border-base-200 items-center gap-2">
@components.SearchForm(components.SearchFormProps{
RootURL: props.BaseURL,
SearchQuery: props.QueryParam,
Target: "#issue-list-container",
})
</div>
<div class="flex p-2 justify-between items-center gap-2">
<div class="flex gap-2">
<div class="flex gap-2 items-center">
<div class="join">
<button class="btn btn-sm join-item btn-active btn-primary">List</button>
<button
@@ -50,44 +48,293 @@ templ DashboardContent(props DashboardProps) {
>
New Issue
</button>
</div>
<div class="flex gap-2">
if props.SelectedIssue != nil {
<button
type="button"
class="btn btn-primary btn-sm"
hx-get={ "/issues/" + props.SelectedIssue.ID + "/edit" }
hx-target="#modal-container"
hx-swap="innerHTML"
>
Edit
</button>
<button
class="btn btn-sm"
hx-get={ "/issues/" + props.SelectedIssue.ID }
hx-target="main"
hx-swap="innerHTML"
hx-push-url="true"
>Details</button>
}
<div class="flex gap-2" x-show="selectedId">
<button
class="btn btn-sm hidden lg:inline-flex"
@click="window.location.href = '/issues/' + selectedId"
>
Full Details
</button>
</div>
</div>
</div>
<div class="flex flex-row flex-1 overflow-hidden">
<div class="w-full flex flex-col border-r border-base-200 overflow-auto">
<div
class="flex flex-1 overflow-hidden relative flex-row"
x-data="{
listWidth: localStorage.getItem('dashboardListWidth') || 33.33,
isResizing: false,
startX: 0,
startWidth: 0,
init() {
this.listWidth = parseFloat(this.listWidth);
},
startResize(e) {
this.isResizing = true;
this.startX = e.clientX || e.touches[0].clientX;
this.startWidth = this.listWidth;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
stopResize() {
if (this.isResizing) {
this.isResizing = false;
localStorage.setItem('dashboardListWidth', this.listWidth);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
},
resize(e) {
if (!this.isResizing) return;
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
const delta = ((clientX - this.startX) / window.innerWidth) * 100;
this.listWidth = Math.max(20, Math.min(60, this.startWidth + delta));
}
}"
@mousemove.window="resize($event)"
@mouseup.window="stopResize()"
@touchmove.window="resize($event)"
@touchend.window="stopResize()"
>
<div
class="flex flex-col border-r border-base-200 overflow-auto bg-base-100 lg:flex lg:w-auto"
:style="`width: ${listWidth}%`"
>
<div id="issue-list-container" class="p-2">
@DashboardIssueList(props.Issues, props.SelectedIssue.ID)
</div>
</div>
<div class="w-full p-2 overflow-auto max-[770px]:hidden">
if props.SelectedIssue != nil {
<div id="issue-detail-container">
@components.IssueDetail(components.IssueDetailProps{
Issue: props.SelectedIssue,
})
<div
class="w-1 bg-base-300 hover:bg-primary cursor-col-resize transition-colors relative z-10"
:class="{ 'bg-primary': isResizing }"
@mousedown="startResize($event)"
@touchstart="startResize($event)"
title="Drag to resize"
>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4 h-8 bg-base-300 rounded-full flex items-center justify-center">
<div class="w-0.5 h-4 bg-base-content/30 rounded-full"></div>
</div>
</div>
<div
class="flex-1 p-4 overflow-auto bg-base-50"
>
<template x-for="issue in issues" :key="issue.id">
<div x-show="issue.id === selectedId" class="issue-detail max-w-3xl mx-auto" x-data="{ editing: null, saving: false, newComment: '', async addComment() { if (!this.newComment.trim()) return; const author = issue.created_by || 'Anonymous'; const response = await fetch('/issues/' + issue.id + '/comments', { method: 'POST', body: new URLSearchParams({text: this.newComment, author: author}) }); const newComment = await response.json(); if (!issue.comments) issue.comments = []; issue.comments.push(newComment); this.newComment = ''; } }">
<div class="card bg-base-100 shadow-md">
<div class="card-body p-2">
<div class="m-2">
<template x-if="editing !== 'title'">
<h2
class="text-2xl font-bold cursor-pointer hover:text-primary"
x-text="issue.title"
@click="editing = 'title'"
></h2>
</template>
<template x-if="editing === 'title'">
<div class="flex gap-2">
<input
type="text"
name="title"
x-model="issue.title"
class="input input-bordered flex-1"
@keydown.enter="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({title: issue.title}) }).then(() => editing = null)"
@keydown.escape="editing = null"
x-init="$el.focus()"
/>
<button class="btn btn-sm btn-primary" @click="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({title: issue.title}) }).then(() => editing = null)">Save</button>
<button class="btn btn-sm" @click="editing = null">Cancel</button>
</div>
</template>
</div>
<div class="flex gap-2 flex-wrap items-center">
<template x-if="editing !== 'status'">
<span
class="badge badge-info badge-sm cursor-pointer hover:badge-primary whitespace-nowrap shrink-0"
x-text="issue.status.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())"
@click="editing = 'status'"
></span>
</template>
<template x-if="editing === 'status'">
<div class="flex items-center gap-2">
<select
name="status"
x-model="issue.status"
class="select select-sm select-bordered"
@change="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({status: issue.status}) }).then(() => editing = null)"
x-init="$nextTick(() => $el.focus())"
>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="blocked">Blocked</option>
<option value="closed">Closed</option>
</select>
<button class="btn btn-xs" @click="editing = null">Cancel</button>
</div>
</template>
<template x-if="editing !== 'type'">
<span
class="badge badge-outline badge-sm cursor-pointer hover:badge-primary whitespace-nowrap"
x-text="issue.issue_type"
@click="editing = 'type'"
></span>
</template>
<template x-if="editing === 'type'">
<div class="flex items-center gap-2">
<select
name="issue_type"
x-model="issue.issue_type"
class="select select-sm select-bordered"
@change="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({issue_type: issue.issue_type}) }).then(() => editing = null)"
x-init="$nextTick(() => $el.focus())"
>
<option value="task">Task</option>
<option value="bug">Bug</option>
<option value="feature">Feature</option>
<option value="chore">Chore</option>
</select>
<button class="btn btn-xs" @click="editing = null">Cancel</button>
</div>
</template>
<template x-if="editing !== 'priority'">
<span
class="badge badge-ghost badge-sm cursor-pointer hover:badge-primary whitespace-nowrap"
x-text="'P' + issue.priority"
@click="editing = 'priority'"
></span>
</template>
<template x-if="editing === 'priority'">
<div class="flex items-center gap-2">
<select
name="priority"
x-model="issue.priority"
class="select select-sm select-bordered"
@change="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({priority: issue.priority}) }).then(() => editing = null)"
x-init="$nextTick(() => $el.focus())"
>
<option value="0">Irrelevant (P0)</option>
<option value="1">Low (P1)</option>
<option value="2">Normal (P2)</option>
<option value="3">High (P3)</option>
<option value="4">Critical (P4)</option>
</select>
<button class="btn btn-xs" @click="editing = null">Cancel</button>
</div>
</template>
</div>
<div class="mt-4">
<h3 class="text-sm font-semibold opacity-70 mb-1">Description</h3>
<template x-if="editing !== 'description'">
<p
class="whitespace-pre-wrap text-sm cursor-pointer hover:text-primary min-h-[20px]"
x-text="issue.description || 'Click to add description...'"
@click="editing = 'description'"
></p>
</template>
<template x-if="editing === 'description'">
<div class="flex flex-col gap-2">
<textarea
name="description"
x-model="issue.description"
class="textarea textarea-bordered"
rows="4"
@keydown.escape="editing = null"
x-init="$el.focus()"
></textarea>
<div class="flex gap-2">
<button
class="btn btn-sm btn-primary"
@click="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({description: issue.description || ''}) }); editing = null"
>Save</button>
<button class="btn btn-sm" @click="editing = null">Cancel</button>
</div>
</div>
</template>
</div>
<div class="divider my-2"></div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="bg-base-200 rounded-lg p-2">
<span class="text-xs opacity-70 block">Created</span>
<p x-text="new Date(issue.created_at).toLocaleDateString()"></p>
</div>
<div class="bg-base-200 rounded-lg p-2">
<span class="text-xs opacity-70 block">Updated</span>
<p x-text="new Date(issue.updated_at).toLocaleDateString()"></p>
</div>
<div class="flex flex-row gap-1 bg-base-200 rounded-lg p-2">
<span class="text-xs opacity-70 block">Created by</span>
<p class="text-xs opacity-70" x-text="issue.created_by"></p>
</div>
<div class="bg-base-200 rounded-lg p-2 cursor-pointer hover:bg-base-300" @click="editing = 'assignee'">
<template x-if="editing !== 'assignee'">
<div>
<span class="text-xs opacity-70 block">Assignee</span>
<p class="text-sm" x-text="issue.assignee || 'Unassigned (click to assign)'"></p>
</div>
</template>
<template x-if="editing === 'assignee'">
<div class="flex flex-col gap-1">
<span class="text-xs opacity-70 block">Assignee</span>
<input
type="text"
name="assignee"
x-model="issue.assignee"
class="input input-sm input-bordered"
placeholder="Enter assignee name"
@keydown.enter="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({assignee: issue.assignee || ''}) }); editing = null"
@keydown.escape="editing = null"
x-init="$el.focus()"
/>
<div class="flex gap-1 mt-1">
<button
class="btn btn-xs btn-primary"
@click="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({assignee: issue.assignee || ''}) }); editing = null"
>Save</button>
<button class="btn btn-xs" @click="editing = null">Cancel</button>
</div>
</div>
</template>
</div>
</div>
<div class="mt-6 w-full">
<h3 class="text-lg font-semibold mb-4">Comments</h3>
<div class="card bg-base-100 shadow-sm mb-3">
<div class="card-body p-3">
<textarea
x-model="newComment"
class="textarea textarea-bordered textarea-sm w-full"
rows="2"
placeholder="Add a comment..."
@keydown.enter.prevent="addComment()"
></textarea>
<div class="card-actions justify-end mt-2">
<button
class="btn btn-primary btn-xs"
@click="addComment()"
:disabled="!newComment.trim()"
>
Add
</button>
</div>
</div>
</div>
<div class="space-y-4">
<template x-for="comment in issue.comments" :key="comment.id">
<div class="card bg-base-200">
<div class="card-body py-3">
<div class="flex justify-between items-start mb-2">
<span class="font-semibold text-sm" x-text="comment.author"></span>
<span class="text-xs opacity-50" x-text="new Date(comment.created_at).toLocaleString()"></span>
</div>
<p class="whitespace-pre-wrap text-sm" x-text="comment.text"></p>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
}
</template>
</div>
</div>
</div>
@@ -116,23 +363,14 @@ templ DashboardIssueRows(issues []*models.Issue, selectedID string) {
<tr><td colspan="5" class="text-center py-4">No issues</td></tr>
}
for _, issue := range issues {
{{
var active string
if selectedID == issue.ID {
active = "bg-primary/10"
}
}}
<tr
hx-get="/"
hx-trigger="click delay:400ms cancel:dblclick"
class={ "hover cursor-pointer min-h-full w-full select-none", active }
hx-target="main"
hx-vals={ `{"selected-issue": "` + issue.ID + `"}` }
hx-swap="innerHTML"
@click={ fmt.Sprintf(`selectedId = '%s'`, issue.ID) }
:class={ fmt.Sprintf(`{ 'bg-primary/10': selectedId === '%s' }`, issue.ID) }
class="hover cursor-pointer min-h-full w-full select-none"
data-issue-id={ issue.ID }
>
<td class="min-w-20 truncate">{ issue.ID }</td>
<td class="truncate max-w-60">{ issue.Title }</td>
<td class="truncate max-w-60" x-text={ fmt.Sprintf(`issues.find(i => i.id === '%s')?.title || %q`, issue.ID, issue.Title) }></td>
<td>
@components.StatusBadge(issue.Status)
</td>

File diff suppressed because one or more lines are too long

View File

@@ -1,77 +1,272 @@
package routes
import (
"fmt"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/pkg/web/components"
)
type IssueDetailProps struct {
Issue *models.Issue
Comments []*models.Comment
From string // "board" when navigating from board view
}
type IssueDetailModalProps struct {
Issue *models.Issue
Comments []*models.Comment
From string
}
templ IssueDetailContent(props IssueDetailProps) {
templ IssueDetailPage(issue *models.Issue, comments []*models.Comment) {
{{
backURL := "/"
editURL := "/issues/" + props.Issue.ID + "/edit"
if props.From == "board" {
backURL = "/?board=true"
editURL += "?from=board"
}
issueJSON, _ := templ.JSONString(issue)
xData := fmt.Sprintf(`{
issue: %s,
editing: null,
newComment: '',
get statusClass() {
const classes = {
'open': 'badge-info',
'in_progress': 'badge-warning',
'blocked': 'badge-error',
'closed': 'badge-success'
}
return classes[this.issue.status] || 'badge-ghost'
},
get priorityLabel() {
const labels = ['Irrelevant', 'Low', 'Normal', 'High', 'Critical']
return labels[this.issue.priority] || 'P' + this.issue.priority
},
async saveField(field, value) {
const formData = new URLSearchParams()
formData.append(field, value || '')
const response = await fetch('/issues/' + this.issue.id, {
method: 'PATCH',
body: formData,
headers: { 'HX-Request': 'true' }
})
this.editing = null
// Check if server wants to redirect back to list
const redirectUrl = response.headers.get('HX-Redirect')
if (redirectUrl) {
window.location.href = redirectUrl
}
},
async addComment() {
if (!this.newComment.trim()) return
const author = this.issue.created_by || 'Anonymous'
const formData = new URLSearchParams()
formData.append('text', this.newComment)
formData.append('author', author)
await fetch('/issues/' + this.issue.id + '/comments', {
method: 'POST',
body: formData,
headers: { 'HX-Request': 'true' }
})
this.newComment = ''
window.location.reload()
}
}`, issueJSON)
}}
<div class="max-w-2xl mx-auto">
<div class="mb-4">
<a
class="btn btn-ghost btn-sm"
hx-get={ backURL }
hx-target="main"
hx-swap="innerHTML"
hx-push-url="true"
>
@components.IconBack()
Back to Issues
</a>
<button
type="button"
class="btn btn-primary btn-sm"
hx-get={ editURL }
hx-target="#modal-container"
hx-swap="innerHTML"
>
Edit
</button>
</div>
<div id="issue-detail-container" class="mb-6">
@components.IssueDetail(components.IssueDetailProps{Issue: props.Issue})
</div>
@components.CommentSection(components.CommentSectionProps{
IssueID: props.Issue.ID,
Comments: props.Comments,
})
</div>
}
templ IssueDetail(props IssueDetailProps) {
@BaseLayout() {
@IssueDetailContent(props)
<div class="max-w-4xl mx-auto p-4" x-data={ xData }>
<div class="flex justify-between items-center mb-6">
<a href="/" class="btn btn-ghost btn-sm">
@components.IconBack()
Back to Issues
</a>
<div class="flex gap-2">
<button
class="btn btn-error btn-sm"
@click="if(confirm('Delete this issue?')) { fetch('/issues/' + issue.id, { method: 'DELETE' }).then(() => window.location.href = '/') }"
>
Delete
</button>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="mb-4">
<template x-if="editing !== 'title'">
<h1
class="card-title text-3xl cursor-pointer hover:text-primary"
@click="editing = 'title'"
x-text="issue.title"
></h1>
</template>
<template x-if="editing === 'title'">
<div class="flex gap-2">
<input
type="text"
name="title"
x-model="issue.title"
class="input input-bordered text-xl flex-1"
@keydown.enter="saveField('title', issue.title)"
@keydown.escape="editing = null"
x-init="$el.focus()"
/>
<button class="btn btn-primary" @click="saveField('title', issue.title)">Save</button>
<button class="btn" @click="editing = null">Cancel</button>
</div>
</template>
<div class="text-sm opacity-60 mt-1" x-text="issue.id"></div>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<template x-if="editing !== 'status'">
<span
:class="'badge ' + statusClass + ' cursor-pointer hover:opacity-80 whitespace-nowrap'"
@click="editing = 'status'"
x-text="issue.status"
></span>
</template>
<template x-if="editing === 'status'">
<select
name="status"
x-model="issue.status"
class="select select-sm select-bordered"
@change="saveField('status', issue.status)"
>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="blocked">Blocked</option>
<option value="closed">Closed</option>
</select>
</template>
<template x-if="editing !== 'type'">
<span
class="badge badge-outline cursor-pointer hover:opacity-80 whitespace-nowrap"
@click="editing = 'type'"
x-text="issue.issue_type"
></span>
</template>
<template x-if="editing === 'type'">
<select
name="issue_type"
x-model="issue.issue_type"
class="select select-sm select-bordered"
@change="saveField('issue_type', issue.issue_type)"
>
<option value="task">Task</option>
<option value="bug">Bug</option>
<option value="feature">Feature</option>
<option value="chore">Chore</option>
</select>
</template>
<template x-if="editing !== 'priority'">
<span
class="badge badge-ghost cursor-pointer hover:opacity-80 whitespace-nowrap"
@click="editing = 'priority'"
x-text="'P' + issue.priority + ' - ' + priorityLabel"
></span>
</template>
<template x-if="editing === 'priority'">
<select
name="priority"
x-model="issue.priority"
class="select select-sm select-bordered"
@change="saveField('priority', issue.priority)"
>
<option value="0">P0 - Irrelevant</option>
<option value="1">P1 - Low</option>
<option value="2">P2 - Normal</option>
<option value="3">P3 - High</option>
<option value="4">P4 - Critical</option>
</select>
</template>
</div>
<div class="mb-6">
<h3 class="text-sm font-semibold opacity-70 mb-2">Description</h3>
<template x-if="editing !== 'description'">
<div
class="min-h-25 p-4 bg-base-200 rounded-lg cursor-pointer hover:bg-base-300"
@click="editing = 'description'"
>
<p class="whitespace-pre-wrap" x-show="issue.description" x-text="issue.description"></p>
<p class="text-base-content/50 italic" x-show="!issue.description">Click to add description...</p>
</div>
</template>
<template x-if="editing === 'description'">
<div class="flex flex-col gap-2">
<textarea
name="description"
x-model="issue.description"
class="textarea textarea-bordered min-h-37.5"
@keydown.escape="editing = null"
x-init="$el.focus()"
></textarea>
<div class="flex gap-2">
<button class="btn btn-primary" @click="saveField('description', issue.description)">Save</button>
<button class="btn" @click="editing = null">Cancel</button>
</div>
</div>
</template>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div class="bg-base-200 rounded-lg p-3">
<span class="text-xs opacity-60 block">Created</span>
<p x-text="new Date(issue.created_at).toLocaleDateString()"></p>
<p class="text-xs opacity-50" x-text="issue.created_by"></p>
</div>
<div class="bg-base-200 rounded-lg p-3">
<span class="text-xs opacity-60 block">Updated</span>
<p x-text="new Date(issue.updated_at).toLocaleDateString()"></p>
</div>
<div
class="bg-base-200 rounded-lg p-3 cursor-pointer hover:bg-base-300"
@click="editing = 'assignee'"
>
<template x-if="editing !== 'assignee'">
<div>
<span class="text-xs opacity-60 block">Assignee</span>
<p x-text="issue.assignee || 'Unassigned'"></p>
</div>
</template>
<template x-if="editing === 'assignee'">
<div>
<span class="text-xs opacity-60 block">Assignee</span>
<input
type="text"
name="assignee"
x-model="issue.assignee"
class="input input-sm input-bordered w-full"
@keydown.enter="saveField('assignee', issue.assignee)"
@keydown.escape="editing = null"
x-init="$el.focus()"
/>
<div class="flex gap-1 mt-1">
<button class="btn btn-xs btn-primary" @click="saveField('assignee', issue.assignee)">Save</button>
<button class="btn btn-xs" @click="editing = null">Cancel</button>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="mt-6">
<h3 class="text-lg font-semibold mb-4">Comments</h3>
<div class="card bg-base-100 shadow mb-4">
<div class="card-body">
<textarea
x-model="newComment"
class="textarea textarea-bordered w-full"
placeholder="Add a comment..."
@keydown.enter.prevent="addComment()"
></textarea>
<div class="card-actions justify-end mt-2">
<button
class="btn btn-primary btn-sm"
@click="addComment()"
:disabled="!newComment.trim()"
>
Add Comment
</button>
</div>
</div>
</div>
<div class="space-y-4">
for _, comment := range comments {
<div class="card bg-base-200">
<div class="card-body py-3">
<div class="flex justify-between items-start mb-2">
<span class="font-semibold text-sm">{ comment.Author }</span>
<span class="text-xs opacity-50">{ comment.CreatedAt.Format("Jan 2, 2006 15:04") }</span>
</div>
<p class="whitespace-pre-wrap text-sm">{ comment.Text }</p>
</div>
</div>
}
</div>
</div>
</div>
}
}
templ IssueDetailModalContent(props IssueDetailModalProps) {
<div class="w-full">
<div id="issue-detail-container" class="mb-6">
@components.IssueDetail(components.IssueDetailProps{Issue: props.Issue})
</div>
@components.CommentSection(components.CommentSectionProps{
IssueID: props.Issue.ID,
Comments: props.Comments,
})
</div>
}

File diff suppressed because one or more lines are too long