Render fixes
This commit is contained in:
parent
d23aa87f75
commit
0e52d7ef98
|
|
@ -660,6 +660,34 @@ func (c *Client) GetAuthenticatedUser() (*APIUser, error) {
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUser looks up a Forgejo user by username.
|
||||||
|
func (c *Client) GetUser(username string) (*APIUser, error) {
|
||||||
|
reqURL := fmt.Sprintf("%s/api/v1/users/%s", c.baseURL, username)
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequest("GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Authorization", "token "+c.apiToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("forgejo API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var user APIUser
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListIssueTimeline fetches the timeline events for an issue.
|
// ListIssueTimeline fetches the timeline events for an issue.
|
||||||
func (c *Client) ListIssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) {
|
func (c *Client) ListIssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/timeline", c.baseURL, owner, repo, number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/timeline", c.baseURL, owner, repo, number)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/forgejo"
|
"github.com/mattnite/forgejo-tickets/internal/forgejo"
|
||||||
|
"github.com/mattnite/forgejo-tickets/internal/markdown"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/models"
|
"github.com/mattnite/forgejo-tickets/internal/models"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
@ -177,13 +178,33 @@ func (h *TicketHandler) Detail(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch timeline for full event view
|
// Fetch comments (includes assets) and timeline (includes events)
|
||||||
|
comments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("forgejo list comments error")
|
||||||
|
}
|
||||||
|
|
||||||
timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
|
timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("forgejo list timeline error")
|
log.Error().Err(err).Msg("forgejo list timeline error")
|
||||||
timelineEvents = nil
|
timelineEvents = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build comment asset lookup (timeline may not include assets)
|
||||||
|
commentAssets := map[int64][]forgejo.Attachment{}
|
||||||
|
for _, cm := range comments {
|
||||||
|
if len(cm.Assets) > 0 {
|
||||||
|
commentAssets[cm.ID] = cm.Assets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range timelineEvents {
|
||||||
|
if timelineEvents[i].Type == "comment" && len(timelineEvents[i].Assets) == 0 {
|
||||||
|
if assets, ok := commentAssets[timelineEvents[i].ID]; ok {
|
||||||
|
timelineEvents[i].Assets = assets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cleanBody, _ := forgejo.StripCommentFooter(issue.Body)
|
cleanBody, _ := forgejo.StripCommentFooter(issue.Body)
|
||||||
timeline := forgejo.BuildTimelineViews(timelineEvents, h.deps.ForgejoClient.BotLogin, true)
|
timeline := forgejo.BuildTimelineViews(timelineEvents, h.deps.ForgejoClient.BotLogin, true)
|
||||||
status := forgejo.DeriveStatus(issue)
|
status := forgejo.DeriveStatus(issue)
|
||||||
|
|
@ -194,13 +215,27 @@ func (h *TicketHandler) Detail(c *gin.Context) {
|
||||||
assigneeNames = append(assigneeNames, a.DisplayName())
|
assigneeNames = append(assigneeNames, a.DisplayName())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract related issue references (admin sees all as links)
|
// Build mention map
|
||||||
allText := cleanBody
|
var allTexts []string
|
||||||
|
allTexts = append(allTexts, cleanBody)
|
||||||
for _, tv := range timeline {
|
for _, tv := range timeline {
|
||||||
if tv.Type == "comment" {
|
if tv.Type == "comment" {
|
||||||
allText += "\n" + tv.Body
|
allTexts = append(allTexts, tv.Body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
usernames := markdown.ExtractMentions(allTexts...)
|
||||||
|
mentions := map[string]string{}
|
||||||
|
for _, username := range usernames {
|
||||||
|
u, err := h.deps.ForgejoClient.GetUser(username)
|
||||||
|
if err != nil {
|
||||||
|
mentions[username] = username
|
||||||
|
} else {
|
||||||
|
mentions[username] = u.DisplayName()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract related issue references (admin sees all as links)
|
||||||
|
allText := strings.Join(allTexts, "\n")
|
||||||
refNumbers := forgejo.ExtractIssueReferences(allText)
|
refNumbers := forgejo.ExtractIssueReferences(allText)
|
||||||
|
|
||||||
var relatedIssues []forgejo.RelatedIssue
|
var relatedIssues []forgejo.RelatedIssue
|
||||||
|
|
@ -242,6 +277,7 @@ func (h *TicketHandler) Detail(c *gin.Context) {
|
||||||
"Repo": repo,
|
"Repo": repo,
|
||||||
"Timeline": timeline,
|
"Timeline": timeline,
|
||||||
"RelatedIssues": relatedIssues,
|
"RelatedIssues": relatedIssues,
|
||||||
|
"Mentions": mentions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/auth"
|
"github.com/mattnite/forgejo-tickets/internal/auth"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/forgejo"
|
"github.com/mattnite/forgejo-tickets/internal/forgejo"
|
||||||
|
"github.com/mattnite/forgejo-tickets/internal/markdown"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/models"
|
"github.com/mattnite/forgejo-tickets/internal/models"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
@ -260,13 +261,35 @@ func (h *TicketHandler) Detail(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch timeline instead of plain comments
|
// Fetch comments (includes assets) and timeline (includes events)
|
||||||
|
comments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("forgejo list comments error")
|
||||||
|
}
|
||||||
|
|
||||||
timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
|
timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("forgejo list timeline error")
|
log.Error().Err(err).Msg("forgejo list timeline error")
|
||||||
timelineEvents = nil
|
timelineEvents = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build comment asset lookup (timeline may not include assets)
|
||||||
|
commentAssets := map[int64][]forgejo.Attachment{}
|
||||||
|
for _, cm := range comments {
|
||||||
|
if len(cm.Assets) > 0 {
|
||||||
|
commentAssets[cm.ID] = cm.Assets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge assets into timeline events
|
||||||
|
for i := range timelineEvents {
|
||||||
|
if timelineEvents[i].Type == "comment" && len(timelineEvents[i].Assets) == 0 {
|
||||||
|
if assets, ok := commentAssets[timelineEvents[i].ID]; ok {
|
||||||
|
timelineEvents[i].Assets = assets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Strip the "Submitted by" footer from the issue body
|
// Strip the "Submitted by" footer from the issue body
|
||||||
cleanBody, _ := forgejo.StripCommentFooter(issue.Body)
|
cleanBody, _ := forgejo.StripCommentFooter(issue.Body)
|
||||||
|
|
||||||
|
|
@ -278,13 +301,27 @@ func (h *TicketHandler) Detail(c *gin.Context) {
|
||||||
assigneeNames = append(assigneeNames, a.DisplayName())
|
assigneeNames = append(assigneeNames, a.DisplayName())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract related issue references
|
// Build mention map: extract @usernames from body + comments, look up display names
|
||||||
allText := cleanBody
|
var allTexts []string
|
||||||
|
allTexts = append(allTexts, cleanBody)
|
||||||
for _, tv := range timeline {
|
for _, tv := range timeline {
|
||||||
if tv.Type == "comment" {
|
if tv.Type == "comment" {
|
||||||
allText += "\n" + tv.Body
|
allTexts = append(allTexts, tv.Body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
usernames := markdown.ExtractMentions(allTexts...)
|
||||||
|
mentions := map[string]string{}
|
||||||
|
for _, username := range usernames {
|
||||||
|
u, err := h.deps.ForgejoClient.GetUser(username)
|
||||||
|
if err != nil {
|
||||||
|
mentions[username] = username
|
||||||
|
} else {
|
||||||
|
mentions[username] = u.DisplayName()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract related issue references
|
||||||
|
allText := strings.Join(allTexts, "\n")
|
||||||
refNumbers := forgejo.ExtractIssueReferences(allText)
|
refNumbers := forgejo.ExtractIssueReferences(allText)
|
||||||
|
|
||||||
// Check visibility of referenced issues
|
// Check visibility of referenced issues
|
||||||
|
|
@ -337,6 +374,7 @@ func (h *TicketHandler) Detail(c *gin.Context) {
|
||||||
"Repo": repo,
|
"Repo": repo,
|
||||||
"Timeline": timeline,
|
"Timeline": timeline,
|
||||||
"RelatedIssues": relatedIssues,
|
"RelatedIssues": relatedIssues,
|
||||||
|
"Mentions": mentions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,7 +399,12 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
body := c.PostForm("body")
|
body := c.PostForm("body")
|
||||||
if body == "" {
|
|
||||||
|
// Check if there are attachments
|
||||||
|
form, _ := c.MultipartForm()
|
||||||
|
hasAttachments := form != nil && len(form.File["attachments"]) > 0
|
||||||
|
|
||||||
|
if body == "" && !hasAttachments {
|
||||||
c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String())
|
c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -373,8 +416,14 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build comment body — use placeholder if only attachments
|
||||||
|
commentBody := body
|
||||||
|
if commentBody == "" {
|
||||||
|
commentBody = "(attached files)"
|
||||||
|
}
|
||||||
|
|
||||||
comment, err := h.deps.ForgejoClient.CreateComment(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, forgejo.CreateCommentRequest{
|
comment, err := h.deps.ForgejoClient.CreateComment(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, forgejo.CreateCommentRequest{
|
||||||
Body: body + "\n\n---\n*Customer comment by: " + user.Email + "*",
|
Body: commentBody + "\n\n---\n*Customer comment by: " + user.Email + "*",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("forgejo create comment error")
|
log.Error().Err(err).Msg("forgejo create comment error")
|
||||||
|
|
@ -382,9 +431,8 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload attachments to comment if any
|
// Upload attachments to comment
|
||||||
form, _ := c.MultipartForm()
|
if hasAttachments {
|
||||||
if form != nil && form.File["attachments"] != nil {
|
|
||||||
for _, fh := range form.File["attachments"] {
|
for _, fh := range form.File["attachments"] {
|
||||||
f, err := fh.Open()
|
f, err := fh.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ package markdown
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
|
|
@ -14,6 +17,12 @@ import (
|
||||||
var (
|
var (
|
||||||
md goldmark.Markdown
|
md goldmark.Markdown
|
||||||
policy *bluemonday.Policy
|
policy *bluemonday.Policy
|
||||||
|
|
||||||
|
// Matches @username in rendered HTML text (not inside tags)
|
||||||
|
mentionRegex = regexp.MustCompile(`(?:^|[\s(>])(@(\w+))`)
|
||||||
|
|
||||||
|
// Matches @username in raw markdown for extraction
|
||||||
|
RawMentionRegex = regexp.MustCompile(`(?:^|[\s(])@(\w+)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -30,16 +39,103 @@ func init() {
|
||||||
)
|
)
|
||||||
|
|
||||||
policy = bluemonday.UGCPolicy()
|
policy = bluemonday.UGCPolicy()
|
||||||
policy.AllowAttrs("class").OnElements("code", "pre", "span", "div")
|
policy.AllowAttrs("class").OnElements("code", "pre", "span", "div", "ul", "li")
|
||||||
policy.AllowAttrs("style").OnElements("span", "pre", "code")
|
policy.AllowAttrs("style").OnElements("span", "pre", "code")
|
||||||
|
// Allow task list checkboxes generated by goldmark GFM
|
||||||
|
policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input")
|
||||||
|
policy.AllowAttrs("checked", "disabled").OnElements("input")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractMentions returns unique @usernames found in the raw markdown text.
|
||||||
|
func ExtractMentions(texts ...string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var result []string
|
||||||
|
for _, text := range texts {
|
||||||
|
for _, m := range RawMentionRegex.FindAllStringSubmatch(text, -1) {
|
||||||
|
username := m[1]
|
||||||
|
if !seen[username] {
|
||||||
|
seen[username] = true
|
||||||
|
result = append(result, username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderMarkdown converts markdown text to sanitized HTML.
|
// RenderMarkdown converts markdown text to sanitized HTML.
|
||||||
func RenderMarkdown(input string) template.HTML {
|
// An optional mentions map (username -> display name) can be passed to style @mentions.
|
||||||
|
func RenderMarkdown(input string, mentions map[string]string) template.HTML {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := md.Convert([]byte(input), &buf); err != nil {
|
if err := md.Convert([]byte(input), &buf); err != nil {
|
||||||
return template.HTML(template.HTMLEscapeString(input))
|
return template.HTML(template.HTMLEscapeString(input))
|
||||||
}
|
}
|
||||||
sanitized := policy.SanitizeBytes(buf.Bytes())
|
sanitized := string(policy.SanitizeBytes(buf.Bytes()))
|
||||||
|
|
||||||
|
if len(mentions) > 0 {
|
||||||
|
sanitized = processMentions(sanitized, mentions)
|
||||||
|
}
|
||||||
|
|
||||||
return template.HTML(sanitized)
|
return template.HTML(sanitized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processMentions replaces @username in HTML text with styled spans.
|
||||||
|
// It avoids replacing inside <code>, <pre>, and <a> tags.
|
||||||
|
func processMentions(html string, mentions map[string]string) string {
|
||||||
|
// Simple approach: split on code/pre blocks, only process outside them
|
||||||
|
// For robustness, just do a string replacement for known usernames
|
||||||
|
for username, displayName := range mentions {
|
||||||
|
old := "@" + username
|
||||||
|
title := template.HTMLEscapeString(displayName)
|
||||||
|
replacement := fmt.Sprintf(`<span class="mention" title="%s">@%s</span>`, title, template.HTMLEscapeString(username))
|
||||||
|
html = replaceOutsideCode(html, old, replacement)
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceOutsideCode replaces old with new in html, but skips content inside <code> and <pre> tags.
|
||||||
|
func replaceOutsideCode(html, old, replacement string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
i := 0
|
||||||
|
for i < len(html) {
|
||||||
|
// Check if we're entering a code or pre block
|
||||||
|
if i < len(html)-1 && html[i] == '<' {
|
||||||
|
lower := strings.ToLower(html[i:])
|
||||||
|
if strings.HasPrefix(lower, "<code") || strings.HasPrefix(lower, "<pre") {
|
||||||
|
// Find the matching close tag
|
||||||
|
var closeTag string
|
||||||
|
if strings.HasPrefix(lower, "<code") {
|
||||||
|
closeTag = "</code>"
|
||||||
|
} else {
|
||||||
|
closeTag = "</pre>"
|
||||||
|
}
|
||||||
|
endIdx := strings.Index(strings.ToLower(html[i:]), closeTag)
|
||||||
|
if endIdx != -1 {
|
||||||
|
endIdx += i + len(closeTag)
|
||||||
|
result.WriteString(html[i:endIdx])
|
||||||
|
i = endIdx
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to match old at current position
|
||||||
|
if i+len(old) <= len(html) && html[i:i+len(old)] == old {
|
||||||
|
// Make sure it's a word boundary (not part of a longer word)
|
||||||
|
before := i > 0 && isWordChar(html[i-1])
|
||||||
|
after := i+len(old) < len(html) && isWordChar(html[i+len(old)])
|
||||||
|
if !before && !after {
|
||||||
|
result.WriteString(replacement)
|
||||||
|
i += len(old)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.WriteByte(html[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWordChar(b byte) bool {
|
||||||
|
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,14 @@ func templateFuncs() template.FuncMap {
|
||||||
label := strings.ReplaceAll(status, "_", " ")
|
label := strings.ReplaceAll(status, "_", " ")
|
||||||
return template.HTML(fmt.Sprintf(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s">%s</span>`, class, label))
|
return template.HTML(fmt.Sprintf(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s">%s</span>`, class, label))
|
||||||
},
|
},
|
||||||
"renderMarkdown": func(input string) template.HTML {
|
"renderMarkdown": func(input string, args ...interface{}) template.HTML {
|
||||||
return markdown.RenderMarkdown(input)
|
var mentions map[string]string
|
||||||
|
if len(args) > 0 {
|
||||||
|
if m, ok := args[0].(map[string]string); ok {
|
||||||
|
mentions = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markdown.RenderMarkdown(input, mentions)
|
||||||
},
|
},
|
||||||
"isOverdue": func(t *time.Time) bool {
|
"isOverdue": func(t *time.Time) bool {
|
||||||
if t == nil {
|
if t == nil {
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,22 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
/* @mention styling */
|
||||||
|
.mention {
|
||||||
|
color: var(--color-blue-600);
|
||||||
|
background-color: var(--color-blue-50);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.mention:hover {
|
||||||
|
background-color: var(--color-blue-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task list checkbox styling */
|
||||||
|
.prose input[type="checkbox"] {
|
||||||
|
margin-right: 0.375rem;
|
||||||
|
accent-color: var(--color-blue-600);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 prose prose-sm max-w-none text-gray-700">
|
<div class="mt-6 prose prose-sm max-w-none text-gray-700">
|
||||||
{{renderMarkdown .Ticket.Description}}
|
{{renderMarkdown .Ticket.Description .Mentions}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Issue Attachments -->
|
<!-- Issue Attachments -->
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
|
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body}}</div>
|
<div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body $.Data.Mentions}}</div>
|
||||||
{{if .Attachments}}
|
{{if .Attachments}}
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
{{range .Attachments}}
|
{{range .Attachments}}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 prose prose-sm max-w-none text-gray-700">
|
<div class="mt-6 prose prose-sm max-w-none text-gray-700">
|
||||||
{{renderMarkdown .Ticket.Description}}
|
{{renderMarkdown .Ticket.Description .Mentions}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Issue Attachments -->
|
<!-- Issue Attachments -->
|
||||||
|
|
@ -90,7 +90,7 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
|
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body}}</div>
|
<div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body $.Data.Mentions}}</div>
|
||||||
{{if .Attachments}}
|
{{if .Attachments}}
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
{{range .Attachments}}
|
{{range .Attachments}}
|
||||||
|
|
@ -120,7 +120,7 @@
|
||||||
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
|
||||||
<div>
|
<div>
|
||||||
<label for="body" class="sr-only">Add a comment</label>
|
<label for="body" class="sr-only">Add a comment</label>
|
||||||
<textarea name="body" id="body" rows="3" required
|
<textarea name="body" id="body" rows="3"
|
||||||
placeholder="Add a comment... (Markdown supported)"
|
placeholder="Add a comment... (Markdown supported)"
|
||||||
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"></textarea>
|
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue