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:
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user