312 lines
10 KiB
Plaintext
312 lines
10 KiB
Plaintext
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
|
|
}
|
|
|
|
type IssueDetailModalProps struct {
|
|
Issue *models.Issue
|
|
Comments []*models.Comment
|
|
From string
|
|
}
|
|
|
|
templ IssueDetailContent(props IssueDetailProps) {
|
|
<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,
|
|
})
|
|
</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)
|
|
}
|
|
}
|
|
|
|
templ IssueDetailModalContent(props IssueDetailModalProps) {
|
|
<div class="w-full">
|
|
<div id="issue-detail-container" class="mb-6">
|
|
@components.IssueDetail(components.IssueDetailProps{Issue: props.Issue, From: props.From})
|
|
</div>
|
|
@components.CommentSection(components.CommentSectionProps{
|
|
IssueID: props.Issue.ID,
|
|
Comments: props.Comments,
|
|
})
|
|
</div>
|
|
}
|