Hypercode/alex/hypercodePublic

Code

  1. hypercode
  2. views
  3. pages
  4. show_ticket.go
show_ticket.go228 lines
package pages

import (
	"fmt"
	"net/http"

	"github.com/hypercodehq/hypercode/database/models"
	"github.com/hypercodehq/libhtml"
	"github.com/hypercodehq/libhtml/attr"
	"github.com/hypercodehq/hypercode/views/components/layouts"
	"github.com/hypercodehq/hypercode/views/components/ui"
)

type ShowTicketData struct {
	User           *models.User
	Repository     *models.Repository
	OwnerUsername  string
	Ticket         *models.Ticket
	Author         *models.User
	Comments       []*models.TicketComment
	CommentAuthors map[int64]*models.User
	CanManage      bool
	StarCount      int64
	HasStarred     bool
	CloneURL       string
	RepositoryURL  string
}

func ShowTicket(r *http.Request, data *ShowTicketData) html.Node {
	if data == nil {
		data = &ShowTicketData{}
	}

	return layouts.Repository(r,
		fmt.Sprintf("#%d %s - Tickets", data.Ticket.Number, data.Ticket.Title),
		layouts.RepositoryLayoutOptions{
			OwnerUsername: data.OwnerUsername,
			RepoName:      data.Repository.Name,
			CurrentTab:    "tickets",
			IsPublic:      data.Repository.Visibility == "public",
			ShowSettings:  data.CanManage,
			StarCount:     data.StarCount,
			HasStarred:    data.HasStarred,
			DefaultBranch: data.Repository.DefaultBranch,
			CloneURL:      data.CloneURL,
			RepositoryURL: data.RepositoryURL,
		},
		html.Main(
			attr.Class("container mx-auto px-4 py-8 max-w-4xl"),
			html.Div(
				attr.Class("space-y-6"),
				// Ticket header
				html.Div(
					attr.Class("flex items-start justify-between gap-4"),
					html.Div(
						attr.Class("flex-1"),
						html.H1(
							attr.Class("text-2xl font-semibold"),
							html.Text(data.Ticket.Title),
							html.Span(
								attr.Class("text-muted-foreground font-normal ml-2"),
								html.Text(fmt.Sprintf("#%d", data.Ticket.Number)),
							),
						),
						html.Div(
							attr.Class("mt-2 flex items-center gap-2"),
							statusBadge(data.Ticket.Status),
							html.Text(fmt.Sprintf("opened %s", formatTime(data.Ticket.CreatedAt))),
						),
					),
					html.If(
						data.User != nil,
						closeReopenButton(data),
					),
				),

				// Ticket body
				html.If(
					data.Ticket.Body != nil && *data.Ticket.Body != "",
					html.Div(
						attr.Class("border rounded-sm p-6 bg-card"),
						html.Div(
							attr.Class("flex items-center gap-3 mb-4 pb-4 border-b"),
							html.Div(
								attr.Class("p-2 rounded-full bg-muted"),
								ui.SVGIcon(ui.IconUser, "size-5"),
							),
							html.If(
								data.Author != nil,
								html.Span(
									attr.Class("font-medium"),
									html.Text(data.Author.DisplayName),
								),
							),
						),
						html.Div(
							attr.Class("prose prose-sm max-w-none"),
							html.Text(*data.Ticket.Body),
						),
					),
				),

				// Comments
				renderComments(data),

				// Comment form
				html.If(
					data.User != nil,
					commentForm(data),
				),
			),
		),
	)
}

func statusBadge(status string) html.Node {
	icon := ui.IconCircle
	classes := "inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium"

	if status == "closed" {
		icon = ui.IconCheck
		classes += " bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300"
	} else {
		classes += " bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300"
	}

	return html.Span(
		attr.Class(classes),
		ui.SVGIcon(icon, "size-3"),
		html.Text(capitalizeFirst(status)),
	)
}

func closeReopenButton(data *ShowTicketData) html.Node {
	if data.Ticket.Status == "open" {
		return html.Form(
			attr.Method("post"),
			attr.Action(fmt.Sprintf("/%s/%s/tickets/%d/close", data.OwnerUsername, data.Repository.Name, data.Ticket.Number)),
			html.Button(
				attr.Type("submit"),
				attr.Class("btn-outline"),
				html.Text("Close ticket"),
			),
		)
	}

	return html.Form(
		attr.Method("post"),
		attr.Action(fmt.Sprintf("/%s/%s/tickets/%d/reopen", data.OwnerUsername, data.Repository.Name, data.Ticket.Number)),
		html.Button(
			attr.Type("submit"),
			attr.Class("btn-outline"),
			html.Text("Reopen ticket"),
		),
	)
}

func renderComments(data *ShowTicketData) html.Node {
	if len(data.Comments) == 0 {
		return html.Div()
	}

	commentNodes := make([]html.Node, len(data.Comments))
	for i, comment := range data.Comments {
		author := data.CommentAuthors[comment.AuthorID]
		commentNodes[i] = renderComment(comment, author)
	}

	return html.Div(
		attr.Class("space-y-4"),
		html.Group(commentNodes...),
	)
}

func renderComment(comment *models.TicketComment, author *models.User) html.Node {
	return html.Div(
		attr.Class("border rounded-sm p-6 bg-card"),
		html.Div(
			attr.Class("flex items-center gap-3 mb-4 pb-4 border-b"),
			html.Div(
				attr.Class("p-2 rounded-full bg-muted"),
				ui.SVGIcon(ui.IconUser, "size-5"),
			),
			html.If(
				author != nil,
				html.Span(
					attr.Class("font-medium"),
					html.Text(author.DisplayName),
				),
			),
			html.Span(
				attr.Class("text-sm text-muted-foreground ml-auto"),
				html.Text(formatTime(comment.CreatedAt)),
			),
		),
		html.Div(
			attr.Class("prose prose-sm max-w-none"),
			html.Text(comment.Body),
		),
	)
}

func commentForm(data *ShowTicketData) html.Node {
	return html.Div(
		attr.Class("border rounded-sm p-6 bg-card"),
		html.Form(
			attr.Method("post"),
			attr.Action(fmt.Sprintf("/%s/%s/tickets/%d/comments", data.OwnerUsername, data.Repository.Name, data.Ticket.Number)),
			attr.Class("space-y-4"),
			html.Textarea(
				attr.Name("body"),
				attr.Id("comment-body"),
				attr.Class("input min-h-[100px]"),
				attr.Placeholder("Leave a comment..."),
				attr.Required(),
			),
			html.Div(
				attr.Class("flex justify-end"),
				html.Button(
					attr.Type("submit"),
					attr.Class("btn-primary"),
					html.Text("Comment"),
				),
			),
		),
	)
}