438 lines
19 KiB
Plaintext
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>
|
|
}
|
|
}
|