Files
LazyPM/pkg/web/routes/dashboard.templ
Robin Olsen d900a5fcd3 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.
2026-03-23 19:26:04 +01:00

386 lines
14 KiB
Plaintext

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"
)
type DashboardProps struct {
Issues []*models.Issue
SelectedIssue *models.Issue
BaseURL string
QueryParam string
EmptyText string
}
templ Dashboard(props DashboardProps) {
@BaseLayout() {
@DashboardContent(props)
}
}
templ DashboardContent(props DashboardProps) {
{{
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 justify-between items-center 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
class="btn btn-sm join-item"
hx-get="/?board=true"
hx-target="main"
hx-swap="innerHTML"
hx-push-url="true"
>Board</button>
</div>
<button
class="btn btn-primary btn-sm"
hx-get="/issues/create"
hx-target="#modal-container"
hx-swap="innerHTML"
>
New Issue
</button>
</div>
<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-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-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>
}
templ DashboardIssueList(issues []*models.Issue, selectedID string) {
<section class="flex">
@base.Table(
[]templ.Component{
base.PlainText("ID"),
base.PlainText("Title"),
base.PlainText("Status"),
base.PlainText("Type"),
base.PlainText("Priority"),
},
[]templ.Component{
DashboardIssueRows(issues, selectedID),
},
templ.Attributes{},
)
</section>
}
templ DashboardIssueRows(issues []*models.Issue, selectedID string) {
if len(issues) == 0 {
<tr><td colspan="5" class="text-center py-4">No issues</td></tr>
}
for _, issue := range issues {
<tr
@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" x-text={ fmt.Sprintf(`issues.find(i => i.id === '%s')?.title || %q`, issue.ID, issue.Title) }></td>
<td>
@components.StatusBadge(issue.Status)
</td>
<td>
@components.TypeBadge(issue.IssueType)
</td>
<td>
@components.PriorityBadge(issue.Priority)
</td>
</tr>
}
}