Files
LazyPM/pkg/web/routes/dashboard.templ

438 lines
19 KiB
Plaintext

package routes
import (
"fmt"
"github.com/LazyBachelor/LazyPM/internal/models"
"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) {
{{
selectedID := ""
if props.SelectedIssue != nil {
selectedID = props.SelectedIssue.ID
}
issuesJSON, _ := templ.JSONString(props.Issues)
xData := fmt.Sprintf(`{ selectedId: %q, issues: %s, getStatusClass(s) { switch(s) { case 'open': return 'badge-info'; case 'in_progress': return 'badge-warning'; case 'closed': return 'badge-success'; case 'blocked': return 'badge-error'; default: return 'badge-ghost'; } }, getTypeClass(t) { switch(t) { case 'bug': return 'badge-outline badge-error'; case 'feature': return 'badge-outline badge-success'; case 'task': return 'badge-outline badge-info'; case 'chore': return 'badge-outline badge-warning'; case 'epic': return 'badge-outline badge-secondary'; default: return 'badge-outline'; } }, getPriorityClass(p) { p = parseInt(p); switch(p) { case 0: case 1: return 'badge-ghost'; case 2: return 'badge-info'; case 3: return 'badge-warning'; case 4: return 'badge-error'; default: return 'badge-ghost'; } }, formatStatus(s) { return (s || '').replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase()); }, formatType(t) { return (t || '').charAt(0).toUpperCase() + (t || '').slice(1); }, formatPriority(p) { p = parseInt(p); switch(p) { case 0: return 'Irrelevant'; case 1: return 'Low'; case 2: return 'Normal'; case 3: return 'High'; case 4: return 'Critical'; default: return 'P'+p; } } }`, selectedID, 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, selectedID)
</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-if="issues.length === 0">
<div class="max-w-2xl mx-auto mt-12">
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body items-center text-center">
<h3 class="text-xl font-semibold">No issues yet</h3>
<p class="text-base-content/70">Create your first issue to get started.</p>
<button class="btn btn-primary btn-sm mt-2" hx-get="/issues/create" hx-target="#modal-container" hx-swap="innerHTML">New Issue</button>
</div>
</div>
</div>
</template>
<template x-if="issues.length > 0 && !selectedId">
<div class="max-w-2xl mx-auto mt-12">
<div class="alert alert-info">Select an issue from the list to view details.</div>
</div>
</template>
<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 saveField(field, value) { const formData = new URLSearchParams(); formData.append(field, value || ''); await fetch('/issues/' + issue.id, { method: 'PATCH', body: formData, headers: { 'HX-Request': 'true' } }); this.editing = null; setTimeout(() => { document.body.dispatchEvent(new CustomEvent('check-status')); }, 1000); }, 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.stop="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}), headers: { 'HX-Request': 'true' } }).then(() => { editing = null; setTimeout(() => { document.body.dispatchEvent(new CustomEvent('check-status')); }, 1000); })"
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.stop="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.stop="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="saveField('description', issue.description)"
>Save</button>
<button class="btn btn-sm" @click.stop="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="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.stop="editing = null">Cancel</button>
</div>
</div>
</template>
</div>
</div>
<div class="mt-4">
<h3 class="text-sm font-semibold opacity-70 mb-2">Dependencies</h3>
<form class="flex gap-2 items-end mb-3">
<div class="flex-1">
<select
class="select select-bordered select-sm w-full"
name="depends_on_id"
x-bind:hx-get="'/issues/' + issue.id + '/dependencies/options'"
hx-trigger="load, dependencies-updated from:body"
hx-target="this"
hx-swap="innerHTML"
>
<option>Loading...</option>
</select>
</div>
<button
type="button"
class="btn btn-sm btn-secondary"
x-bind:hx-post="'/issues/' + issue.id + '/dependencies?container_id=deps-' + issue.id"
x-bind:hx-target="'#deps-' + issue.id"
hx-swap="innerHTML"
hx-include="closest form"
>
Add
</button>
</form>
<div
x-bind:id="'deps-' + issue.id"
x-bind:hx-get="'/issues/' + issue.id + '/dependencies?container_id=deps-' + issue.id"
hx-trigger="load, dependencies-updated from:body"
hx-swap="innerHTML"
>
<div class="text-sm text-base-content/60">Loading dependencies...</div>
</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 }
hx-get={ "/issues/" + issue.ID + "/edit" }
hx-trigger="dblclick"
hx-target="#modal-container"
hx-swap="innerHTML"
>
<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 x-html={ fmt.Sprintf(`'<span class="badge ' + getStatusClass(issues.find(i => i.id === '%s')?.status) + ' badge-sm whitespace-nowrap">' + formatStatus(issues.find(i => i.id === '%s')?.status || '%s') + '</span>'`, issue.ID, issue.ID, issue.Status) }></td>
<td x-html={ fmt.Sprintf(`'<span class="badge ' + getTypeClass(issues.find(i => i.id === '%s')?.issue_type) + ' badge-sm whitespace-nowrap">' + formatType(issues.find(i => i.id === '%s')?.issue_type || '%s') + '</span>'`, issue.ID, issue.ID, issue.IssueType) }></td>
<td x-html={ fmt.Sprintf(`'<span class="badge ' + getPriorityClass(issues.find(i => i.id === '%s')?.priority) + ' badge-sm whitespace-nowrap">' + formatPriority(issues.find(i => i.id === '%s')?.priority || %d) + '</span>'`, issue.ID, issue.ID, issue.Priority) }></td>
</tr>
}
}