Hypercode/alex/hypercodePublic

Code

  1. hypercode
  2. views
  3. pages
  4. tickets_list.go
tickets_list.go224 lines
package pages

import (
	"fmt"
	"net/http"
	"time"

	"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 TicketsListData struct {
	User          *models.User
	Repository    *models.Repository
	OwnerUsername string
	Tickets       []*models.Ticket
	StatusFilter  string
	OpenCount     int64
	ClosedCount   int64
	CanManage     bool
	StarCount     int64
	HasStarred    bool
	CloneURL      string
	RepositoryURL string
}

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

	return layouts.Repository(r,
		"Tickets - "+data.OwnerUsername+"/"+data.Repository.Name,
		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-7xl"),
			html.Div(
				attr.Class("space-y-6"),
				// Header with New Ticket button
				html.Div(
					attr.Class("flex justify-between items-center"),
					html.H1(
						attr.Class("text-2xl font-semibold"),
						html.Text("Tickets"),
					),
					html.If(
						data.User != nil,
						html.A(
							attr.Href("/"+data.OwnerUsername+"/"+data.Repository.Name+"/tickets/new"),
							attr.Class("btn-primary inline-flex items-center gap-2"),
							ui.SVGIcon(ui.IconPlus, "size-4"),
							html.Text("New ticket"),
						),
					),
				),

				// Tickets card
				ui.Card(ui.CardProps{
					Class: "!pt-1",
					Content: html.Div(
						attr.Class("space-y-4"),
						// Filter tabs
						html.Div(
							attr.Class("flex flex-wrap items-center gap-4 -mx-6 px-6 border-b"),
							filterTab("open", data.StatusFilter, data.OpenCount, data.OwnerUsername, data.Repository.Name),
							filterTab("closed", data.StatusFilter, data.ClosedCount, data.OwnerUsername, data.Repository.Name),
						),

						// Tickets list
						html.Div(
							attr.Class("-mx-6 -mb-6"),
							renderTicketsList(data),
						),
					),
				}),
			),
		),
	)
}

func filterTab(status, currentStatus string, count int64, owner, repo string) html.Node {
	isActive := status == currentStatus
	href := fmt.Sprintf("/%s/%s/tickets?status=%s", owner, repo, status)

	icon := ui.IconCircle
	if status == "closed" {
		icon = ui.IconCheck
	}

	spanClasses := "btn-ghost inline-flex items-center gap-2"
	if isActive {
		spanClasses += " font-medium"
	} else {
		spanClasses += " text-muted-foreground"
	}

	borderClass := "border-transparent"
	if isActive {
		borderClass = "border-zinc-900"
	}

	return html.A(
		attr.Href(href),
		attr.Class("inline-flex mt-2 pb-2 border-b-2 transition-colors "+borderClass),
		html.Span(
			attr.Class(spanClasses),
			ui.SVGIcon(icon, "size-4"),
			html.Text(fmt.Sprintf("%s (%d)", capitalizeFirst(status), count)),
		),
	)
}

func renderTicketsList(data *TicketsListData) html.Node {
	if len(data.Tickets) == 0 {
		return html.Div(
			attr.Class("py-8"),
			ui.EmptyState(ui.EmptyStateProps{
				Icon:        ui.SVGIcon(ui.IconCircle, "size-6"),
				Title:       fmt.Sprintf("No %s tickets", data.StatusFilter),
				Description: fmt.Sprintf("There are no %s tickets for this repository.", data.StatusFilter),
				ShowAction:  false,
			}),
		)
	}

	ticketItems := make([]html.Node, len(data.Tickets))
	for i, ticket := range data.Tickets {
		ticketItems[i] = renderTicketItem(data.OwnerUsername, data.Repository.Name, ticket)
	}

	return html.Div(
		attr.Class("divide-y"),
		html.Group(ticketItems...),
	)
}

func renderTicketItem(owner, repo string, ticket *models.Ticket) html.Node {
	ticketURL := fmt.Sprintf("/%s/%s/tickets/%d", owner, repo, ticket.Number)

	statusIcon := ui.IconCircle
	statusColor := "text-green-600"
	if ticket.Status == "closed" {
		statusIcon = ui.IconCheck
		statusColor = "text-purple-600"
	}

	return html.Div(
		attr.Class("p-4 hover:bg-muted/50 transition-colors"),
		html.A(
			attr.Href(ticketURL),
			attr.Class("flex items-start gap-3"),
			html.Div(
				attr.Class(statusColor+" flex-shrink-0 mt-1"),
				ui.SVGIcon(statusIcon, "size-5"),
			),
			html.Div(
				attr.Class("flex-1 min-w-0"),
				html.Div(
					attr.Class("flex items-start gap-2"),
					html.H3(
						attr.Class("font-medium text-foreground hover:text-primary"),
						html.Text(ticket.Title),
					),
				),
				html.Div(
					attr.Class("mt-1 text-sm text-muted-foreground"),
					html.Text(fmt.Sprintf("#%d opened %s", ticket.Number, formatTime(ticket.CreatedAt))),
				),
			),
		),
	)
}

func formatTime(unixTimestamp int64) string {
	t := time.Unix(unixTimestamp, 0)
	now := time.Now()
	diff := now.Sub(t)

	if diff < time.Minute {
		return "just now"
	} else if diff < time.Hour {
		minutes := int(diff.Minutes())
		if minutes == 1 {
			return "1 minute ago"
		}
		return fmt.Sprintf("%d minutes ago", minutes)
	} else if diff < 24*time.Hour {
		hours := int(diff.Hours())
		if hours == 1 {
			return "1 hour ago"
		}
		return fmt.Sprintf("%d hours ago", hours)
	} else if diff < 30*24*time.Hour {
		days := int(diff.Hours() / 24)
		if days == 1 {
			return "1 day ago"
		}
		return fmt.Sprintf("%d days ago", days)
	} else {
		return t.Format("Jan 2, 2006")
	}
}

func capitalizeFirst(s string) string {
	if len(s) == 0 {
		return s
	}
	return string(s[0]-32) + s[1:]
}