Merge remote-tracking branch 'origin/main' into LPM-115
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/LazyBachelor/LazyPM/internal/models"
|
||||
"github.com/LazyBachelor/LazyPM/pkg/web/components"
|
||||
)
|
||||
@@ -8,7 +9,6 @@ import (
|
||||
type IssueDetailProps struct {
|
||||
Issue *models.Issue
|
||||
Comments []*models.Comment
|
||||
From string // "board" when navigating from board view
|
||||
}
|
||||
|
||||
type IssueDetailModalProps struct {
|
||||
@@ -18,50 +18,8 @@ type IssueDetailModalProps struct {
|
||||
}
|
||||
|
||||
templ IssueDetailContent(props IssueDetailProps) {
|
||||
{{
|
||||
backURL := "/"
|
||||
editURL := "/issues/" + props.Issue.ID + "/edit"
|
||||
if props.From == "board" {
|
||||
backURL = "/?board=true"
|
||||
editURL += "?from=board"
|
||||
}
|
||||
}}
|
||||
<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>
|
||||
if props.Issue.Status != "closed" {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-warning btn-sm btn-outline"
|
||||
hx-get={ "/issues/" + props.Issue.ID + "/close" }
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Close issue
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div id="issue-detail-container" class="mb-6">
|
||||
@components.IssueDetail(components.IssueDetailProps{Issue: props.Issue, From: props.From})
|
||||
</div>
|
||||
<div class="max-w-4xl mx-auto p-4">
|
||||
@components.IssueDetail(components.IssueDetailProps{Issue: props.Issue, From: ""})
|
||||
@components.CommentSection(components.CommentSectionProps{
|
||||
IssueID: props.Issue.ID,
|
||||
Comments: props.Comments,
|
||||
@@ -69,6 +27,271 @@ templ IssueDetailContent(props IssueDetailProps) {
|
||||
</div>
|
||||
}
|
||||
|
||||
templ IssueDetailPage(issue *models.Issue, comments []*models.Comment) {
|
||||
{{
|
||||
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)
|
||||
}}
|
||||
@BaseLayout() {
|
||||
<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 IssueDetail(props IssueDetailProps) {
|
||||
@BaseLayout() {
|
||||
@IssueDetailContent(props)
|
||||
|
||||
Reference in New Issue
Block a user