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

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>
}