Preview images for Issue cards in Project Board view (#22112)

Original Issue: https://github.com/go-gitea/gitea/issues/22102

This addition would be a big benefit for design and art teams using the
issue tracking.

The preview will be the latest "image type" attachments on an issue-
simple, and allows for automatic updates of the cover image as issue
progress is made!

This would make Gitea competitive with Trello... wouldn't it be amazing
to say goodbye to Atlassian products? Ha.

First image is the most recent, the SQL will fetch up to 5 latest images
(URL string).

All images supported by browsers plus upcoming formats: *.avif *.bmp
*.gif *.jpg *.jpeg *.jxl *.png *.svg *.webp

The CSS will try to center-align images until it cannot, then it will
left align with overflow hidden. Single images get to be slightly
larger!

Tested so far on: Chrome, Firefox, Android Chrome, Android Firefox.

Current revision with light and dark themes:

![image](https://user-images.githubusercontent.com/24665/207066878-58e6bf73-0c93-4caa-8d40-38f4432b3578.png)


![image](https://user-images.githubusercontent.com/24665/207066555-293f65c3-e706-4888-8516-de8ec632d638.png)

---------

Co-authored-by: Jason Song <i@wolfogre.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: delvh <dev.lh@web.de>
This commit is contained in:
Nathaniel Sabanski 2023-02-11 00:12:41 -08:00 committed by GitHub
parent e9288c2477
commit fb1a2a13f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 175 additions and 19 deletions

View File

@ -455,6 +455,8 @@ var migrations = []Migration{
NewMigration("Add scope for access_token", v1_19.AddScopeForAccessTokens), NewMigration("Add scope for access_token", v1_19.AddScopeForAccessTokens),
// v240 -> v241 // v240 -> v241
NewMigration("Add actions tables", v1_19.AddActionsTables), NewMigration("Add actions tables", v1_19.AddActionsTables),
// v241 -> v242
NewMigration("Add card_type column to project table", v1_19.AddCardTypeToProjectTable),
} }
// GetCurrentDBVersion returns the current db version // GetCurrentDBVersion returns the current db version

View File

@ -0,0 +1,17 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_19 //nolint
import (
"xorm.io/xorm"
)
// AddCardTypeToProjectTable: add CardType column, setting existing rows to CardTypeTextOnly
func AddCardTypeToProjectTable(x *xorm.Engine) error {
type Project struct {
CardType int `xorm:"NOT NULL"`
}
return x.Sync(new(Project))
}

View File

@ -19,6 +19,9 @@ type (
// BoardType is used to represent a project board type // BoardType is used to represent a project board type
BoardType uint8 BoardType uint8
// CardType is used to represent a project board card type
CardType uint8
// BoardList is a list of all project boards in a repository // BoardList is a list of all project boards in a repository
BoardList []*Board BoardList []*Board
) )
@ -34,6 +37,14 @@ const (
BoardTypeBugTriage BoardTypeBugTriage
) )
const (
// CardTypeTextOnly is a project board card type that is text only
CardTypeTextOnly CardType = iota
// CardTypeImagesAndText is a project board card type that has images and text
CardTypeImagesAndText
)
// BoardColorPattern is a regexp witch can validate BoardColor // BoardColorPattern is a regexp witch can validate BoardColor
var BoardColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$") var BoardColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
@ -85,6 +96,16 @@ func IsBoardTypeValid(p BoardType) bool {
} }
} }
// IsCardTypeValid checks if the project board card type is valid
func IsCardTypeValid(p CardType) bool {
switch p {
case CardTypeTextOnly, CardTypeImagesAndText:
return true
default:
return false
}
}
func createBoardsForProjectsType(ctx context.Context, project *Project) error { func createBoardsForProjectsType(ctx context.Context, project *Project) error {
var items []string var items []string

View File

@ -19,12 +19,18 @@ import (
) )
type ( type (
// ProjectsConfig is used to identify the type of board that is being created // BoardConfig is used to identify the type of board that is being created
ProjectsConfig struct { BoardConfig struct {
BoardType BoardType BoardType BoardType
Translation string Translation string
} }
// CardConfig is used to identify the type of board card that is being used
CardConfig struct {
CardType CardType
Translation string
}
// Type is used to identify the type of project in question and ownership // Type is used to identify the type of project in question and ownership
Type uint8 Type uint8
) )
@ -91,6 +97,7 @@ type Project struct {
CreatorID int64 `xorm:"NOT NULL"` CreatorID int64 `xorm:"NOT NULL"`
IsClosed bool `xorm:"INDEX"` IsClosed bool `xorm:"INDEX"`
BoardType BoardType BoardType BoardType
CardType CardType
Type Type Type Type
RenderedContent string `xorm:"-"` RenderedContent string `xorm:"-"`
@ -145,15 +152,23 @@ func init() {
db.RegisterModel(new(Project)) db.RegisterModel(new(Project))
} }
// GetProjectsConfig retrieves the types of configurations projects could have // GetBoardConfig retrieves the types of configurations project boards could have
func GetProjectsConfig() []ProjectsConfig { func GetBoardConfig() []BoardConfig {
return []ProjectsConfig{ return []BoardConfig{
{BoardTypeNone, "repo.projects.type.none"}, {BoardTypeNone, "repo.projects.type.none"},
{BoardTypeBasicKanban, "repo.projects.type.basic_kanban"}, {BoardTypeBasicKanban, "repo.projects.type.basic_kanban"},
{BoardTypeBugTriage, "repo.projects.type.bug_triage"}, {BoardTypeBugTriage, "repo.projects.type.bug_triage"},
} }
} }
// GetCardConfig retrieves the types of configurations project board cards could have
func GetCardConfig() []CardConfig {
return []CardConfig{
{CardTypeTextOnly, "repo.projects.card_type.text_only"},
{CardTypeImagesAndText, "repo.projects.card_type.images_and_text"},
}
}
// IsTypeValid checks if a project type is valid // IsTypeValid checks if a project type is valid
func IsTypeValid(p Type) bool { func IsTypeValid(p Type) bool {
switch p { switch p {
@ -237,6 +252,10 @@ func NewProject(p *Project) error {
p.BoardType = BoardTypeNone p.BoardType = BoardTypeNone
} }
if !IsCardTypeValid(p.CardType) {
p.CardType = CardTypeTextOnly
}
if !IsTypeValid(p.Type) { if !IsTypeValid(p.Type) {
return util.NewInvalidArgumentErrorf("project type is not valid") return util.NewInvalidArgumentErrorf("project type is not valid")
} }
@ -280,9 +299,14 @@ func GetProjectByID(ctx context.Context, id int64) (*Project, error) {
// UpdateProject updates project properties // UpdateProject updates project properties
func UpdateProject(ctx context.Context, p *Project) error { func UpdateProject(ctx context.Context, p *Project) error {
if !IsCardTypeValid(p.CardType) {
p.CardType = CardTypeTextOnly
}
_, err := db.GetEngine(ctx).ID(p.ID).Cols( _, err := db.GetEngine(ctx).ID(p.ID).Cols(
"title", "title",
"description", "description",
"card_type",
).Update(p) ).Update(p)
return err return err
} }

View File

@ -53,6 +53,7 @@ func TestProject(t *testing.T) {
project := &Project{ project := &Project{
Type: TypeRepository, Type: TypeRepository,
BoardType: BoardTypeBasicKanban, BoardType: BoardTypeBasicKanban,
CardType: CardTypeTextOnly,
Title: "New Project", Title: "New Project",
RepoID: 1, RepoID: 1,
CreatedUnix: timeutil.TimeStampNow(), CreatedUnix: timeutil.TimeStampNow(),

View File

@ -132,6 +132,21 @@ func GetAttachmentsByIssueID(ctx context.Context, issueID int64) ([]*Attachment,
return attachments, db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0", issueID).Find(&attachments) return attachments, db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0", issueID).Find(&attachments)
} }
// GetAttachmentsByIssueIDImagesLatest returns the latest image attachments of an issue.
func GetAttachmentsByIssueIDImagesLatest(ctx context.Context, issueID int64) ([]*Attachment, error) {
attachments := make([]*Attachment, 0, 5)
return attachments, db.GetEngine(ctx).Where(`issue_id = ? AND (name like '%.apng'
OR name like '%.avif'
OR name like '%.bmp'
OR name like '%.gif'
OR name like '%.jpg'
OR name like '%.jpeg'
OR name like '%.jxl'
OR name like '%.png'
OR name like '%.svg'
OR name like '%.webp')`, issueID).Desc("comment_id").Limit(5).Find(&attachments)
}
// GetAttachmentsByCommentID returns all attachments if comment by given ID. // GetAttachmentsByCommentID returns all attachments if comment by given ID.
func GetAttachmentsByCommentID(ctx context.Context, commentID int64) ([]*Attachment, error) { func GetAttachmentsByCommentID(ctx context.Context, commentID int64) ([]*Attachment, error) {
attachments := make([]*Attachment, 0, 10) attachments := make([]*Attachment, 0, 10)

View File

@ -1231,6 +1231,9 @@ projects.board.color = "Color"
projects.open = Open projects.open = Open
projects.close = Close projects.close = Close
projects.board.assigned_to = Assigned to projects.board.assigned_to = Assigned to
projects.card_type.desc = "Card Previews"
projects.card_type.images_and_text = "Images and Text"
projects.card_type.text_only = "Text Only"
issues.desc = Organize bug reports, tasks and milestones. issues.desc = Organize bug reports, tasks and milestones.
issues.filter_assignees = Filter Assignee issues.filter_assignees = Filter Assignee

View File

@ -121,7 +121,7 @@ func canWriteUnit(ctx *context.Context) bool {
// NewProject render creating a project page // NewProject render creating a project page
func NewProject(ctx *context.Context) { func NewProject(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.projects.new") ctx.Data["Title"] = ctx.Tr("repo.projects.new")
ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig() ctx.Data["BoardTypes"] = project_model.GetBoardConfig()
ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink()
shared_user.RenderUserHeader(ctx) shared_user.RenderUserHeader(ctx)
@ -137,7 +137,7 @@ func NewProjectPost(ctx *context.Context) {
if ctx.HasError() { if ctx.HasError() {
ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
ctx.Data["PageIsViewProjects"] = true ctx.Data["PageIsViewProjects"] = true
ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig() ctx.Data["BoardTypes"] = project_model.GetBoardConfig()
ctx.HTML(http.StatusOK, tplProjectsNew) ctx.HTML(http.StatusOK, tplProjectsNew)
return return
} }

View File

@ -13,6 +13,7 @@ import (
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
project_model "code.gitea.io/gitea/models/project" project_model "code.gitea.io/gitea/models/project"
attachment_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
@ -123,7 +124,8 @@ func Projects(ctx *context.Context) {
// NewProject render creating a project page // NewProject render creating a project page
func NewProject(ctx *context.Context) { func NewProject(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.projects.new") ctx.Data["Title"] = ctx.Tr("repo.projects.new")
ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig() ctx.Data["BoardTypes"] = project_model.GetBoardConfig()
ctx.Data["CardTypes"] = project_model.GetCardConfig()
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.HTML(http.StatusOK, tplProjectsNew) ctx.HTML(http.StatusOK, tplProjectsNew)
} }
@ -135,7 +137,8 @@ func NewProjectPost(ctx *context.Context) {
if ctx.HasError() { if ctx.HasError() {
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig() ctx.Data["BoardTypes"] = project_model.GetBoardConfig()
ctx.Data["CardTypes"] = project_model.GetCardConfig()
ctx.HTML(http.StatusOK, tplProjectsNew) ctx.HTML(http.StatusOK, tplProjectsNew)
return return
} }
@ -146,6 +149,7 @@ func NewProjectPost(ctx *context.Context) {
Description: form.Content, Description: form.Content,
CreatorID: ctx.Doer.ID, CreatorID: ctx.Doer.ID,
BoardType: form.BoardType, BoardType: form.BoardType,
CardType: form.CardType,
Type: project_model.TypeRepository, Type: project_model.TypeRepository,
}); err != nil { }); err != nil {
ctx.ServerError("NewProject", err) ctx.ServerError("NewProject", err)
@ -212,6 +216,7 @@ func EditProject(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.projects.edit") ctx.Data["Title"] = ctx.Tr("repo.projects.edit")
ctx.Data["PageIsEditProjects"] = true ctx.Data["PageIsEditProjects"] = true
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["CardTypes"] = project_model.GetCardConfig()
p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
if err != nil { if err != nil {
@ -229,6 +234,7 @@ func EditProject(ctx *context.Context) {
ctx.Data["title"] = p.Title ctx.Data["title"] = p.Title
ctx.Data["content"] = p.Description ctx.Data["content"] = p.Description
ctx.Data["card_type"] = p.CardType
ctx.HTML(http.StatusOK, tplProjectsNew) ctx.HTML(http.StatusOK, tplProjectsNew)
} }
@ -239,6 +245,7 @@ func EditProjectPost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.projects.edit") ctx.Data["Title"] = ctx.Tr("repo.projects.edit")
ctx.Data["PageIsEditProjects"] = true ctx.Data["PageIsEditProjects"] = true
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["CardTypes"] = project_model.GetCardConfig()
if ctx.HasError() { if ctx.HasError() {
ctx.HTML(http.StatusOK, tplProjectsNew) ctx.HTML(http.StatusOK, tplProjectsNew)
@ -261,6 +268,7 @@ func EditProjectPost(ctx *context.Context) {
p.Title = form.Title p.Title = form.Title
p.Description = form.Content p.Description = form.Content
p.CardType = form.CardType
if err = project_model.UpdateProject(ctx, p); err != nil { if err = project_model.UpdateProject(ctx, p); err != nil {
ctx.ServerError("UpdateProjects", err) ctx.ServerError("UpdateProjects", err)
return return
@ -302,6 +310,18 @@ func ViewProject(ctx *context.Context) {
return return
} }
if project.CardType != project_model.CardTypeTextOnly {
issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment)
for _, issuesList := range issuesMap {
for _, issue := range issuesList {
if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil {
issuesAttachmentMap[issue.ID] = issueAttachment
}
}
}
ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap
}
linkedPrsMap := make(map[int64][]*issues_model.Issue) linkedPrsMap := make(map[int64][]*issues_model.Issue)
for _, issuesList := range issuesMap { for _, issuesList := range issuesMap {
for _, issue := range issuesList { for _, issue := range issuesList {

View File

@ -512,6 +512,7 @@ type CreateProjectForm struct {
Title string `binding:"Required;MaxSize(100)"` Title string `binding:"Required;MaxSize(100)"`
Content string Content string
BoardType project_model.BoardType BoardType project_model.BoardType
CardType project_model.CardType
} }
// UserCreateProjectForm is a from for creating an individual or organization // UserCreateProjectForm is a from for creating an individual or organization
@ -520,6 +521,7 @@ type UserCreateProjectForm struct {
Title string `binding:"Required;MaxSize(100)"` Title string `binding:"Required;MaxSize(100)"`
Content string Content string
BoardType project_model.BoardType BoardType project_model.BoardType
CardType project_model.CardType
UID int64 `binding:"Required"` UID int64 `binding:"Required"`
} }

View File

@ -36,7 +36,7 @@
<input type="hidden" name="board_type" value="{{.type}}"> <input type="hidden" name="board_type" value="{{.type}}">
<div class="default text">{{.locale.Tr "repo.projects.template.desc_helper"}}</div> <div class="default text">{{.locale.Tr "repo.projects.template.desc_helper"}}</div>
<div class="menu"> <div class="menu">
{{range $element := .ProjectTypes}} {{range $element := .BoardTypes}}
<div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{$.locale.Tr $element.Translation}}</div> <div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{$.locale.Tr $element.Translation}}</div>
{{end}} {{end}}
</div> </div>

View File

@ -34,17 +34,38 @@
</div> </div>
{{if not .PageIsEditProjects}} {{if not .PageIsEditProjects}}
<label>{{.locale.Tr "repo.projects.template.desc"}}</label> <div class="field">
<div class="ui selection dropdown"> <label>{{.locale.Tr "repo.projects.template.desc"}}</label>
<input type="hidden" name="board_type" value="{{.type}}"> <div class="ui selection dropdown">
<div class="default text">{{.locale.Tr "repo.projects.template.desc_helper"}}</div> <input type="hidden" name="board_type" value="{{.type}}">
<div class="menu"> <div class="default text">{{.locale.Tr "repo.projects.template.desc_helper"}}</div>
{{range $element := .ProjectTypes}} <div class="menu">
<div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{$.locale.Tr $element.Translation}}</div> {{range $element := .BoardTypes}}
{{end}} <div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{$.locale.Tr $element.Translation}}</div>
{{end}}
</div>
</div> </div>
</div> </div>
{{end}} {{end}}
<div class="field">
<label>{{.locale.Tr "repo.projects.card_type.desc"}}</label>
<div class="ui selection dropdown">
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
{{range $element := .CardTypes}}
{{if or (eq $.card_type $element.CardType) (and (not $.card_type) (eq $element.CardType 2))}}
<input type="hidden" name="card_type" value="{{$element.CardType}}">
<div class="default text">{{$.locale.Tr $element.Translation}}</div>
{{end}}
{{end}}
<div class="menu">
{{range $element := .CardTypes}}
<div class="item" data-id="{{$element.CardType}}" data-value="{{$element.CardType}}">{{$.locale.Tr $element.Translation}}</div>
{{end}}
</div>
</div>
</div>
</div> </div>
<div class="ui container"> <div class="ui container">
<div class="ui divider"></div> <div class="ui divider"></div>

View File

@ -179,6 +179,13 @@
<!-- start issue card --> <!-- start issue card -->
<div class="card board-card" data-issue="{{.ID}}"> <div class="card board-card" data-issue="{{.ID}}">
{{if eq $.Project.CardType 1}}{{/* Images and Text*/}}
<div class="card-attachment-images">
{{range (index $.issuesAttachmentMap .ID)}}
<img src="{{.DownloadURL}}" alt="{{.Name}}" />
{{end}}
</div>
{{end}}
<div class="content p-0"> <div class="content p-0">
<div class="header"> <div class="header">
<span class="dif ac vm {{if .IsClosed}}red{{else}}green{{end}}"> <span class="dif ac vm {{if .IsClosed}}red{{else}}green{{end}}">

View File

@ -48,7 +48,7 @@
<input type="hidden" name="board_type" value="{{.type}}"> <input type="hidden" name="board_type" value="{{.type}}">
<div class="default text">{{.locale.Tr "repo.projects.template.desc_helper"}}</div> <div class="default text">{{.locale.Tr "repo.projects.template.desc_helper"}}</div>
<div class="menu"> <div class="menu">
{{range $element := .ProjectTypes}} {{range $element := .BoardTypes}}
<div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{$.locale.Tr $element.Translation}}</div> <div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{$.locale.Tr $element.Translation}}</div>
{{end}} {{end}}
</div> </div>

View File

@ -72,6 +72,10 @@
margin-right: auto !important; margin-right: auto !important;
} }
.board-column .ui.cards > .card > .content {
border: none;
}
.board-card { .board-card {
margin: 4px 2px !important; margin: 4px 2px !important;
border-radius: 5px !important; border-radius: 5px !important;
@ -90,6 +94,25 @@
font-size: 16px !important; font-size: 16px !important;
} }
.board-card .card-attachment-images {
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-align: center;
}
.board-card .card-attachment-images img {
display: inline-block;
max-height: 50px;
border-radius: var(--border-radius);
margin-right: 2px;
}
.board-card .card-attachment-images img:only-child {
max-height: 90px;
margin: auto;
}
.card-ghost { .card-ghost {
border-style: dashed !important; border-style: dashed !important;
background: none !important; background: none !important;