From cfa01d6b013fb9f9ce44021b80f5987192186808 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sat, 3 Feb 2024 17:45:32 +0100
Subject: [PATCH 01/46] feat(board-notes): add, edit and delete of BoardNotes

---
 models/project/board.go              |  20 ++++
 models/project/note.go               | 159 +++++++++++++++++++++++++++
 models/project/project.go            |  19 ++++
 routers/web/repo/projects.go         | 120 ++++++++++++++++++++
 routers/web/web.go                   |  11 ++
 services/forms/repo_form.go          |   6 +
 templates/projects/view.tmpl         |  46 +++++++-
 templates/repo/note.tmpl             |  71 ++++++++++++
 web_src/css/features/projects.css    |   4 +
 web_src/css/index.css                |   1 +
 web_src/css/repo/note-card.css       |  21 ++++
 web_src/js/features/repo-projects.js | 100 +++++++++++++++--
 12 files changed, 566 insertions(+), 12 deletions(-)
 create mode 100644 models/project/note.go
 create mode 100644 templates/repo/note.tmpl
 create mode 100644 web_src/css/repo/note-card.css

diff --git a/models/project/board.go b/models/project/board.go
index 3e2d8e0472c51..d49d59e8ffd59 100644
--- a/models/project/board.go
+++ b/models/project/board.go
@@ -82,6 +82,26 @@ func (b *Board) NumIssues(ctx context.Context) int {
 	return int(c)
 }
 
+// NumNotes return counter of all notes assigned to the board
+func (b *Board) NumNotes(ctx context.Context) int {
+	c, err := db.GetEngine(ctx).Table("board_note").
+		And("board_id=?", b.ID).
+		GroupBy("id").
+		Cols("id").
+		Count()
+	if err != nil {
+		return 0
+	}
+	return int(c)
+}
+
+// NumIssuesAndNotes return counter of all issues and notes assigned to the board
+func (b *Board) NumIssuesAndNotes(ctx context.Context) int {
+	numIssues := b.NumIssues(ctx)
+	numNotes := b.NumNotes(ctx)
+	return numIssues + numNotes
+}
+
 func init() {
 	db.RegisterModel(new(Board))
 }
diff --git a/models/project/note.go b/models/project/note.go
new file mode 100644
index 0000000000000..855efddf62bf8
--- /dev/null
+++ b/models/project/note.go
@@ -0,0 +1,159 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package project
+
+import (
+	"context"
+	"fmt"
+
+	"code.gitea.io/gitea/models/db"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/builder"
+)
+
+// BoardNote is used to represent a note on a boards
+type BoardNote struct {
+	ID      int64  `xorm:"pk autoincr"`
+	Title   string `xorm:"TEXT NOT NULL"`
+	Content string `xorm:"LONGTEXT"`
+
+	BoardID   int64            `xorm:"INDEX NOT NULL"`
+	CreatorID int64            `xorm:"NOT NULL"`
+	Creator   *user_model.User `xorm:"-"`
+
+	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+}
+
+type BoardNoteList = []*BoardNote
+
+// NotesOptions represents options of an note.
+type NotesOptions struct { //nolint
+	db.Paginator
+	BoardID int64
+}
+
+func init() {
+	db.RegisterModel(new(BoardNote))
+}
+
+// GetBoardNoteById load notes assigned to the boards
+func GetBoardNoteById(ctx context.Context, noteID int64) (*BoardNote, error) {
+	note := new(BoardNote)
+
+	has, err := db.GetEngine(ctx).ID(noteID).Get(note)
+	if err != nil {
+		return nil, err
+	} else if !has {
+		return nil, ErrProjectBoardNoteNotExist{BoardNoteID: noteID}
+	}
+
+	return note, nil
+}
+
+// LoadNotesFromBoardList load notes assigned to the boards
+func (p *Project) LoadNotesFromBoardList(ctx context.Context, bs BoardList) (map[int64]BoardNoteList, error) {
+	notesMap := make(map[int64]BoardNoteList, len(bs))
+	for i := range bs {
+		il, err := LoadNotesFromBoard(ctx, bs[i])
+		if err != nil {
+			return nil, err
+		}
+		notesMap[bs[i].ID] = il
+	}
+	return notesMap, nil
+}
+
+// LoadNotesFromBoard load notes assigned to this board
+func LoadNotesFromBoard(ctx context.Context, board *Board) (BoardNoteList, error) {
+	noteList := make(BoardNoteList, 0, 5)
+
+	if board.ID != 0 {
+		notes, err := BoardNotes(ctx, &NotesOptions{
+			BoardID: board.ID,
+		})
+		if err != nil {
+			return nil, err
+		}
+		noteList = notes
+	}
+
+	return noteList, nil
+}
+
+// BoardNotes returns a list of notes by given conditions.
+func BoardNotes(ctx context.Context, opts *NotesOptions) (BoardNoteList, error) {
+	sess := db.GetEngine(ctx)
+
+	if opts.BoardID != 0 {
+		if opts.BoardID > 0 {
+			sess.Where(builder.Eq{"board_id": opts.BoardID})
+		} else {
+			sess.Where(builder.Eq{"board_id": 0})
+		}
+	}
+
+	notes := BoardNoteList{}
+	if err := sess.Find(&notes); err != nil {
+		return nil, fmt.Errorf("unable to query Notes: %w", err)
+	}
+
+	for _, note := range notes {
+		creator := new(user_model.User)
+		has, err := db.GetEngine(ctx).ID(note.CreatorID).Get(creator)
+		if err != nil {
+			return nil, err
+		}
+		if !has {
+			return nil, user_model.ErrUserNotExist{UID: note.CreatorID}
+		}
+		note.Creator = creator
+	}
+
+	return notes, nil
+}
+
+// NewBoardNote adds a new note to a given board
+func NewBoardNote(ctx context.Context, note *BoardNote) error {
+	_, err := db.GetEngine(ctx).Insert(note)
+	return err
+}
+
+// GetLastEventTimestamp returns the last user visible event timestamp, either the creation of this issue or the close.
+func (note *BoardNote) GetLastEventTimestamp() timeutil.TimeStamp {
+	return max(note.CreatedUnix, note.UpdatedUnix)
+}
+
+// GetLastEventLabel returns the localization label for the current note.
+func (note *BoardNote) GetLastEventLabel() string {
+	if note.UpdatedUnix > note.CreatedUnix {
+		return "repo.projects.note.edited_by"
+	}
+	return "repo.projects.note.created_by"
+}
+
+// UpdateBoardNote changes a BoardNote
+func UpdateBoardNote(ctx context.Context, note *BoardNote) error {
+	var fieldToUpdate []string
+
+	if note.Title != "" {
+		fieldToUpdate = append(fieldToUpdate, "title")
+	}
+
+	fieldToUpdate = append(fieldToUpdate, "content")
+
+	_, err := db.GetEngine(ctx).ID(note.ID).Cols(fieldToUpdate...).Update(note)
+
+	return err
+}
+
+// DeleteBoardNote removes the BoardNote from the project board.
+func DeleteBoardNote(ctx context.Context, boardNote *BoardNote) error {
+	if _, err := db.GetEngine(ctx).Delete(boardNote); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/models/project/project.go b/models/project/project.go
index d2fca6cdc8a8a..eca49f85a0d15 100644
--- a/models/project/project.go
+++ b/models/project/project.go
@@ -85,6 +85,25 @@ func (err ErrProjectBoardNotExist) Unwrap() error {
 	return util.ErrNotExist
 }
 
+// ErrProjectBoardNoteNotExist represents a "ProjectBoardNotExist" kind of error.
+type ErrProjectBoardNoteNotExist struct {
+	BoardNoteID int64
+}
+
+// IsErrProjectBoardNoteNotExist checks if an error is a ErrProjectBoardNoteNotExist
+func IsErrProjectBoardNoteNotExist(err error) bool {
+	_, ok := err.(ErrProjectBoardNoteNotExist)
+	return ok
+}
+
+func (err ErrProjectBoardNoteNotExist) Error() string {
+	return fmt.Sprintf("project board-note does not exist [id: %d]", err.BoardNoteID)
+}
+
+func (err ErrProjectBoardNoteNotExist) Unwrap() error {
+	return util.ErrNotExist
+}
+
 // Project represents a project board
 type Project struct {
 	ID          int64                  `xorm:"pk autoincr"`
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 4908bb796d9dc..c2c9ccc410b18 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -324,6 +324,12 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
+	notesMap, err := project.LoadNotesFromBoardList(ctx, boards)
+	if err != nil {
+		ctx.ServerError("LoadNotesOfBoards", err)
+		return
+	}
+
 	if project.CardType != project_model.CardTypeTextOnly {
 		issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment)
 		for _, issuesList := range issuesMap {
@@ -376,6 +382,7 @@ func ViewProject(ctx *context.Context) {
 	ctx.Data["Project"] = project
 	ctx.Data["IssuesMap"] = issuesMap
 	ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend
+	ctx.Data["NotesMap"] = notesMap
 
 	ctx.HTML(http.StatusOK, tplProjectsView)
 }
@@ -698,3 +705,116 @@ func MoveIssues(ctx *context.Context) {
 
 	ctx.JSONOK()
 }
+
+func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.BoardNote) {
+	if ctx.Doer == nil {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only signed in users are allowed to perform this action.",
+		})
+		return nil, nil
+	}
+
+	if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only authorized users are allowed to perform this action.",
+		})
+		return nil, nil
+	}
+
+	project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("", nil)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return nil, nil
+	}
+
+	note, err := project_model.GetBoardNoteById(ctx, ctx.ParamsInt64(":noteID"))
+	if err != nil {
+		if project_model.IsErrProjectBoardNoteNotExist(err) {
+			ctx.NotFound("", nil)
+		} else {
+			ctx.ServerError("GetBoardNoteById", err)
+		}
+		return nil, nil
+	}
+	boardID := ctx.ParamsInt64(":boardID")
+	if note.BoardID != boardID {
+		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
+			"message": fmt.Sprintf("ProjectBoardNote[%d] is not in Board[%d] as expected", note.ID, boardID),
+		})
+		return nil, nil
+	}
+
+	if project.RepoID != ctx.Repo.Repository.ID {
+		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
+			"message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", note.ID, ctx.Repo.Repository.ID),
+		})
+		return nil, nil
+	}
+	return project, note
+}
+
+func AddNoteToBoard(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.BoardNoteForm)
+	if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only authorized users are allowed to perform this action.",
+		})
+		return
+	}
+
+	if err := project_model.NewBoardNote(ctx, &project_model.BoardNote{
+		Title:   form.Title,
+		Content: form.Content,
+
+		BoardID:   ctx.ParamsInt64(":boardID"),
+		CreatorID: ctx.Doer.ID,
+	}); err != nil {
+		ctx.ServerError("NewProjectBoard", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
+
+func EditBoardNote(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.BoardNoteForm)
+	_, note := checkProjectBoardNoteChangePermissions(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	if form.Title != "" {
+		note.Title = form.Title
+	}
+
+	if form.Content != "" {
+		note.Content = form.Content
+	}
+
+	if err := project_model.UpdateBoardNote(ctx, note); err != nil {
+		ctx.ServerError("UpdateProjectBoardNote", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
+
+func DeleteBoardNote(ctx *context.Context) {
+	_, boardNote := checkProjectBoardNoteChangePermissions(ctx)
+
+	if err := project_model.DeleteBoardNote(ctx, boardNote); err != nil {
+		ctx.ServerError("DeleteBoardNote", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
+
+func MoveBoardNote(ctx *context.Context) {
+
+	ctx.JSONOK()
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 92cf5132b45b6..c2cf620c175f9 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1341,6 +1341,17 @@ func registerRoutes(m *web.Route) {
 						m.Post("/unsetdefault", repo.UnSetDefaultProjectBoard)
 
 						m.Post("/move", repo.MoveIssues)
+
+						m.Group("/note", func() {
+							m.Post("", web.Bind(forms.BoardNoteForm{}), repo.AddNoteToBoard)
+
+							m.Group("/{noteID}", func() {
+								m.Put("", web.Bind(forms.BoardNoteForm{}), repo.EditBoardNote)
+								m.Delete("", repo.DeleteBoardNote)
+
+								m.Post("/move", repo.MoveBoardNote)
+							})
+						})
 					})
 				})
 			}, reqRepoProjectsWriter, context.RepoMustNotBeArchived())
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index 845eccf817d3a..c412ada440379 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -533,6 +533,12 @@ type EditProjectBoardForm struct {
 	Color   string `binding:"MaxSize(7)"`
 }
 
+// BoardNoteForm is a form for editing/creating a note to a board
+type BoardNoteForm struct {
+	Title   string `binding:"Required;MaxSize(255)"`
+	Content string
+}
+
 //    _____  .__.__                   __
 //   /     \ |__|  |   ____   _______/  |_  ____   ____   ____
 //  /  \ /  \|  |  | _/ __ \ /  ___/\   __\/  _ \ /    \_/ __ \
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index b3ad03c354988..f34d2461dc07d 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -70,7 +70,7 @@
 				<div class="project-column-header">
 					<div class="ui large label project-column-title gt-py-2">
 						<div class="ui small circular grey label project-column-issue-count">
-							{{.NumIssues ctx}}
+							{{.NumIssuesAndNotes ctx}}
 						</div>
 						{{.Title}}
 					</div>
@@ -80,6 +80,12 @@
 								{{svg "octicon-kebab-horizontal"}}
 							</div>
 							<div class="menu user-menu">
+								<a class="item show-modal button show-add-project-column-note-modal"
+									data-modal="#add-project-column-note-modal-{{.ID}}"
+									data-url="{{$.Link}}/{{.ID}}/note">
+										{{svg "octicon-note"}}
+										{{ctx.Locale.Tr "repo.projects.note.add"}}
+								</a>
 								<a class="item show-modal button" data-modal="#edit-project-column-modal-{{.ID}}">
 									{{svg "octicon-pencil"}}
 									{{ctx.Locale.Tr "repo.projects.column.edit"}}
@@ -110,6 +116,25 @@
 									{{ctx.Locale.Tr "repo.projects.column.delete"}}
 								</a>
 
+								<div class="ui small modal" id="add-project-column-note-modal-{{.ID}}">
+									<div class="header">
+										{{ctx.Locale.Tr "repo.projects.note.add"}}
+									</div>
+									<div class="content">
+										<div class="required field">
+											<label for="new-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
+											<input id="new-project-column-note-title-{{.ID}}" name="title" maxlength="255" required>
+										</div>
+
+										<div class="field content-field">
+											<label for="new-project-column-note-content-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.content"}}</label>
+											<textarea id="new-project-column-note-content-{{.ID}}" name="content"></textarea>
+										</div>
+									</div>
+
+									{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm" "ModalButtonOkText" (ctx.Locale.Tr "repo.projects.note.add"))}}
+								</div>
+
 								<div class="ui small modal edit-project-column-modal" id="edit-project-column-modal-{{.ID}}">
 									<div class="header">
 										{{ctx.Locale.Tr "repo.projects.column.edit"}}
@@ -165,8 +190,23 @@
 
 				<div class="divider"></div>
 
-				<div class="ui cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}">
-					{{range (index $.IssuesMap .ID)}}
+				{{ $columnID := .ID }}
+				{{if or $canWriteProject (index $.NotesMap $columnID)}}
+					<div class="ui cards note-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
+						{{range (index $.NotesMap $columnID)}}
+							<div class="note-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-note="{{.ID}}">
+								{{template "repo/note" (dict "BoardNote" . "CanWriteProjects" $canWriteProject "Link" (print $.Link "/" $columnID))}}
+							</div>
+						{{end}}
+					</div>
+
+					{{if or $canWriteProject (index $.IssuesMap $columnID)}}
+						<div class="divider"></div>
+					{{end}}
+				{{end}}
+
+				<div class="ui cards issue-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="issue-board-{{.ID}}">
+					{{range (index $.IssuesMap $columnID)}}
 						<div class="issue-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-issue="{{.ID}}">
 							{{template "repo/issue/card" (dict "Issue" . "Page" $)}}
 						</div>
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
new file mode 100644
index 0000000000000..9e950d412d989
--- /dev/null
+++ b/templates/repo/note.tmpl
@@ -0,0 +1,71 @@
+{{with .BoardNote}}
+<div class="content gt-p-0 gt-w-100">
+	<div class="gt-df gt-items-start">
+		<div class="project-column-note-card-icon">
+			{{svg "octicon-note"}}
+		</div>
+		<span class="note-card-title muted project-column-note-title">{{.Title}}</span>
+		{{ if $.CanWriteProjects }}
+			<div class="ui dropdown jump item">
+				<div class="gt-px-3">
+					{{svg "octicon-kebab-horizontal"}}
+				</div>
+				<div class="menu user-menu left">
+					<a class="item show-modal button show-edit-project-column-note-modal"
+						data-modal="#edit-project-column-note-modal-{{.ID}}"
+						data-url="{{$.Link}}/note/{{.ID}}">
+							{{svg "octicon-pencil"}}
+							{{ctx.Locale.Tr "repo.projects.note.edit"}}
+					</a>
+					<a class="item show-modal button show-delete-project-column-note-modal"
+						data-modal="#delete-project-column-note-modal-{{.ID}}"
+						data-url="{{$.Link}}/note/{{.ID}}">
+							{{svg "octicon-trash"}}
+							{{ctx.Locale.Tr "repo.projects.note.delete"}}
+					</a>
+
+					<div class="ui small modal" id="edit-project-column-note-modal-{{.ID}}">
+						<div class="header">
+							{{ctx.Locale.Tr "repo.projects.note.edit"}}
+						</div>
+						<div class="content">
+							<div class="required field">
+								<label for="edit-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
+								<input id="edit-project-column-note-title-{{.ID}}" name="title" value="{{.Title}}" maxlength="255" required>
+							</div>
+
+							<div class="field content-field">
+								<label for="edit-project-column-note-content-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.content"}}</label>
+								<textarea id="edit-project-column-note-content-{{.ID}}" name="content">{{.Content}}</textarea>
+							</div>
+						</div>
+
+						{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm" "ModalButtonOkText" (ctx.Locale.Tr "repo.projects.note.edit"))}}
+					</div>
+
+					<div class="ui g-modal-confirm modal" id="delete-project-column-note-modal-{{.ID}}">
+						<div class="header">
+							{{ctx.Locale.Tr "repo.projects.note.delete"}}
+						</div>
+						<div class="content">
+							<label>
+								{{ctx.Locale.Tr "repo.projects.note.deletion_desc"}}
+							</label>
+						</div>
+						{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
+					</div>
+				</div>
+			</div>
+		{{end}}
+	</div>
+	<div class="project-column-note-content">
+		{{.Content}}
+	</div>
+	<div class="meta gt-my-2">
+		<span class="text light grey muted-links">
+			{{$timeStr := TimeSinceUnix .GetLastEventTimestamp ctx.Locale}}
+			{{ctx.Locale.Tr .GetLastEventLabel $timeStr (.Creator.HomeLink | Escape) (.Creator.GetDisplayName | Escape) | Safe}}
+		</span>
+	</div>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index f85430a2a80ed..3285b151463e4 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -60,6 +60,10 @@
   gap: .25rem;
 }
 
+.project-column > .note-cards {
+  flex: unset;
+}
+
 .project-column > .divider {
   margin: 5px 0;
 }
diff --git a/web_src/css/index.css b/web_src/css/index.css
index f893531b781cb..a83a9ebd01d6f 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -45,6 +45,7 @@
 @import "./repo/issue-label.css";
 @import "./repo/issue-list.css";
 @import "./repo/list-header.css";
+@import "./repo/note-card.css";
 @import "./repo/linebutton.css";
 @import "./repo/wiki.css";
 @import "./repo/header.css";
diff --git a/web_src/css/repo/note-card.css b/web_src/css/repo/note-card.css
new file mode 100644
index 0000000000000..1d682111ed913
--- /dev/null
+++ b/web_src/css/repo/note-card.css
@@ -0,0 +1,21 @@
+.note-card {
+  display: flex;
+  flex-direction: column;
+  align-items: start;
+  border-radius: var(--border-radius);
+  padding: 8px 10px;
+  border: 1px solid var(--color-secondary);
+  background: var(--color-card);
+}
+
+.note-card-icon,
+.note-card-unpin {
+  margin-top: 1px;
+  flex-shrink: 0;
+}
+
+.note-card-title {
+  flex: 1;
+  font-size: 18px;
+  margin-left: 4px;
+}
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index 5a2a7e72ef335..aa21cfd4074bd 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -11,17 +11,17 @@ function updateIssueCount(cards) {
   parent.getElementsByClassName('project-column-issue-count')[0].textContent = cnt;
 }
 
-function createNewColumn(url, columnTitle, projectColorInput) {
+function sendPostRequestAndUnsetDirty(url, form, data) {
   $.ajax({
     url,
-    data: JSON.stringify({title: columnTitle.val(), color: projectColorInput.val()}),
+    data: JSON.stringify(data),
     headers: {
       'X-Csrf-Token': csrfToken,
     },
     contentType: 'application/json',
     method: 'POST',
   }).done(() => {
-    columnTitle.closest('form').removeClass('dirty');
+    form.removeClass('dirty');
     window.location.reload();
   });
 }
@@ -87,15 +87,25 @@ async function initRepoProjectSortable() {
   });
 
   for (const boardColumn of boardColumns) {
-    const boardCardList = boardColumn.getElementsByClassName('cards')[0];
-    createSortable(boardCardList, {
-      group: 'shared',
+    const boardIssueCardList = boardColumn.getElementsByClassName('issue-cards')[0];
+    const boardNoteCardList = boardColumn.getElementsByClassName('note-cards')[0];
+    createSortable(boardIssueCardList, {
+      group: 'issue-shared',
       animation: 150,
       ghostClass: 'card-ghost',
       onAdd: moveIssue,
       onUpdate: moveIssue,
       delayOnTouchOnly: true,
-      delay: 500,
+      delay: 500
+    });
+    createSortable(boardNoteCardList, {
+      group: 'note-shared',
+      animation: 150,
+      ghostClass: 'card-ghost',
+      onAdd: () => { console.error('@TODO') },
+      onUpdate: () => { console.error('@TODO') },
+      delayOnTouchOnly: true,
+      delay: 500
     });
   }
 }
@@ -183,7 +193,7 @@ export function initRepoProject() {
     });
   });
 
-  $('#new_project_column_submit').on('click', (e) => {
+  $('#new_project_column_submit').on('click', function (e) {
     e.preventDefault();
     const columnTitle = $('#new_project_column');
     const projectColorInput = $('#new_project_column_color_picker');
@@ -191,7 +201,79 @@ export function initRepoProject() {
       return;
     }
     const url = $(this).data('url');
-    createNewColumn(url, columnTitle, projectColorInput);
+    const form = columnTitle.closest('form');
+    sendPostRequestAndUnsetDirty(url, form, {title: columnTitle.val(), color: projectColorInput.val()});
+  });
+
+  $('.show-add-project-column-note-modal').each(function () {
+    const addNoteUrl = $(this).data('url');
+    const modalId = $(this).data('modal');
+    const addModal = $(modalId);
+
+    const confirmAddButton = addModal.find('.actions > .ok.button');
+    confirmAddButton.on('click', (e) => {
+      e.preventDefault();
+      const noteTitleInput = addModal.find('[name="title"]');
+      const contentTextarea = addModal.find('[name="content"]');
+      if (!noteTitleInput.val()) {
+        return;
+      }
+      const form = noteTitleInput.closest('form');
+      sendPostRequestAndUnsetDirty(addNoteUrl, form, {title: noteTitleInput.val(), content: contentTextarea.val()});
+    });
+  });
+  $('.show-edit-project-column-note-modal').each(function () {
+    const editNoteUrl = $(this).data('url');
+    const modalId = $(this).data('modal');
+    const wrapperCard = $(this).closest('.note-card');
+
+    const noteTitle = wrapperCard.find('.project-column-note-title');
+    const noteContent = wrapperCard.find('.project-column-note-content');
+
+    const editModal = wrapperCard.find(modalId);
+    const noteTitleInput = editModal.find('[name="title"]');
+    const noteContentTextarea = editModal.find('[name="content"]');
+
+    const confirmEditButton = editModal.find('.actions > .ok.button');
+    confirmEditButton.on('click', (e) => {
+      e.preventDefault();
+
+      $.ajax({
+        url: editNoteUrl,
+        data: JSON.stringify({title: noteTitleInput.val(), content: noteContentTextarea.val()}),
+        headers: {
+          'X-Csrf-Token': csrfToken,
+        },
+        contentType: 'application/json',
+        method: 'PUT',
+      }).done(() => {
+        noteTitle.text(noteTitleInput.val());
+        noteContent.text(noteContentTextarea.val());
+        editModal.removeClass('dirty');
+        $('.ui.modal').modal('hide');
+      });
+    });
+  });
+  $('.show-delete-project-column-note-modal').each(function () {
+    const deleteNoteUrl = $(this).data('url');
+    const modalId = $(this).data('modal');
+    const deletModal = $(modalId);
+
+    const confirmDeleteButton = deletModal.find('.actions > .ok.button');
+    confirmDeleteButton.on('click', (e) => {
+      e.preventDefault();
+
+      $.ajax({
+        url: deleteNoteUrl,
+        headers: {
+          'X-Csrf-Token': csrfToken,
+        },
+        contentType: 'application/json',
+        method: 'DELETE',
+      }).done(() => {
+        window.location.reload();
+      });
+    });
   });
 }
 

From 79f5d58c441a8b34ea64af0d7603f04ca7fe3758 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sat, 3 Feb 2024 18:02:11 +0100
Subject: [PATCH 02/46] feat(board-notes): styles for card-content and modals

---
 templates/projects/view.tmpl   | 16 +++++++++-------
 templates/repo/note.tmpl       | 28 ++++++++++++++++------------
 web_src/css/repo/note-card.css |  6 ++++++
 3 files changed, 31 insertions(+), 19 deletions(-)

diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index f34d2461dc07d..a951356164052 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -121,14 +121,16 @@
 										{{ctx.Locale.Tr "repo.projects.note.add"}}
 									</div>
 									<div class="content">
-										<div class="required field">
-											<label for="new-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
-											<input id="new-project-column-note-title-{{.ID}}" name="title" maxlength="255" required>
-										</div>
+										<div class="ui form">
+											<div class="required field">
+												<label for="new-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
+												<input id="new-project-column-note-title-{{.ID}}" name="title" maxlength="255" required>
+											</div>
 
-										<div class="field content-field">
-											<label for="new-project-column-note-content-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.content"}}</label>
-											<textarea id="new-project-column-note-content-{{.ID}}" name="content"></textarea>
+											<div class="field content-field">
+												<label for="new-project-column-note-content-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.content"}}</label>
+												<textarea id="new-project-column-note-content-{{.ID}}" name="content"></textarea>
+											</div>
 										</div>
 									</div>
 
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 9e950d412d989..85fa7a9aff8fe 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -29,14 +29,16 @@
 							{{ctx.Locale.Tr "repo.projects.note.edit"}}
 						</div>
 						<div class="content">
-							<div class="required field">
-								<label for="edit-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
-								<input id="edit-project-column-note-title-{{.ID}}" name="title" value="{{.Title}}" maxlength="255" required>
-							</div>
-
-							<div class="field content-field">
-								<label for="edit-project-column-note-content-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.content"}}</label>
-								<textarea id="edit-project-column-note-content-{{.ID}}" name="content">{{.Content}}</textarea>
+							<div class="ui form">
+								<div class="required field">
+									<label for="edit-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
+									<input id="edit-project-column-note-title-{{.ID}}" name="title" value="{{.Title}}" maxlength="255" required>
+								</div>
+	
+								<div class="field content-field">
+									<label for="edit-project-column-note-content-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.content"}}</label>
+									<textarea id="edit-project-column-note-content-{{.ID}}" name="content">{{.Content}}</textarea>
+								</div>
 							</div>
 						</div>
 
@@ -58,10 +60,12 @@
 			</div>
 		{{end}}
 	</div>
-	<div class="project-column-note-content">
-		{{.Content}}
-	</div>
-	<div class="meta gt-my-2">
+	{{if and .Content (gt (len .Content) 0)}}
+		<div class="project-column-note-content">
+			{{.Content}}
+		</div>
+	{{end}}
+	<div class="meta">
 		<span class="text light grey muted-links">
 			{{$timeStr := TimeSinceUnix .GetLastEventTimestamp ctx.Locale}}
 			{{ctx.Locale.Tr .GetLastEventLabel $timeStr (.Creator.HomeLink | Escape) (.Creator.GetDisplayName | Escape) | Safe}}
diff --git a/web_src/css/repo/note-card.css b/web_src/css/repo/note-card.css
index 1d682111ed913..5cf2b8dc3fc16 100644
--- a/web_src/css/repo/note-card.css
+++ b/web_src/css/repo/note-card.css
@@ -8,6 +8,12 @@
   background: var(--color-card);
 }
 
+.note-card > .content {
+  display: flex;
+  flex-direction: column;
+  row-gap: 0.5rem;
+}
+
 .note-card-icon,
 .note-card-unpin {
   margin-top: 1px;

From c668d8eec2e58c1bcbbab6b7a9b62d76bb347016 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sat, 3 Feb 2024 21:13:54 +0100
Subject: [PATCH 03/46] feat(board-notes): move notes like issues

---
 models/project/note.go               | 65 +++++++++++---------
 routers/web/repo/projects.go         | 92 +++++++++++++++++++++++++++-
 routers/web/web.go                   |  3 +-
 templates/projects/view.tmpl         |  4 +-
 web_src/css/features/projects.css    | 10 +++
 web_src/js/features/repo-projects.js | 41 ++++++++++---
 6 files changed, 173 insertions(+), 42 deletions(-)

diff --git a/models/project/note.go b/models/project/note.go
index 855efddf62bf8..540dc8a949dd6 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -19,6 +19,7 @@ type BoardNote struct {
 	ID      int64  `xorm:"pk autoincr"`
 	Title   string `xorm:"TEXT NOT NULL"`
 	Content string `xorm:"LONGTEXT"`
+	Sorting int64  `xorm:"NOT NULL DEFAULT 0"`
 
 	BoardID   int64            `xorm:"INDEX NOT NULL"`
 	CreatorID int64            `xorm:"NOT NULL"`
@@ -54,6 +55,17 @@ func GetBoardNoteById(ctx context.Context, noteID int64) (*BoardNote, error) {
 	return note, nil
 }
 
+// GetBoardNoteByIds return notes with the given IDs.
+func GetBoardNoteByIds(ctx context.Context, noteIDs []int64) (BoardNoteList, error) {
+	notes := make(BoardNoteList, 0, len(noteIDs))
+
+	if err := db.GetEngine(ctx).In("id", noteIDs).Find(&notes); err != nil {
+		return nil, err
+	}
+
+	return notes, nil
+}
+
 // LoadNotesFromBoardList load notes assigned to the boards
 func (p *Project) LoadNotesFromBoardList(ctx context.Context, bs BoardList) (map[int64]BoardNoteList, error) {
 	notesMap := make(map[int64]BoardNoteList, len(bs))
@@ -69,35 +81,24 @@ func (p *Project) LoadNotesFromBoardList(ctx context.Context, bs BoardList) (map
 
 // LoadNotesFromBoard load notes assigned to this board
 func LoadNotesFromBoard(ctx context.Context, board *Board) (BoardNoteList, error) {
-	noteList := make(BoardNoteList, 0, 5)
-
-	if board.ID != 0 {
-		notes, err := BoardNotes(ctx, &NotesOptions{
-			BoardID: board.ID,
-		})
-		if err != nil {
-			return nil, err
-		}
-		noteList = notes
+	notes, err := BoardNotes(ctx, &NotesOptions{
+		BoardID: board.ID,
+	})
+	if err != nil {
+		return nil, err
 	}
 
-	return noteList, nil
+	return notes, nil
 }
 
 // BoardNotes returns a list of notes by given conditions.
 func BoardNotes(ctx context.Context, opts *NotesOptions) (BoardNoteList, error) {
 	sess := db.GetEngine(ctx)
 
-	if opts.BoardID != 0 {
-		if opts.BoardID > 0 {
-			sess.Where(builder.Eq{"board_id": opts.BoardID})
-		} else {
-			sess.Where(builder.Eq{"board_id": 0})
-		}
-	}
+	sess.Where(builder.Eq{"board_id": max(opts.BoardID, 0)})
 
 	notes := BoardNoteList{}
-	if err := sess.Find(&notes); err != nil {
+	if err := sess.OrderBy("Sorting").Find(&notes); err != nil {
 		return nil, fmt.Errorf("unable to query Notes: %w", err)
 	}
 
@@ -130,24 +131,30 @@ func (note *BoardNote) GetLastEventTimestamp() timeutil.TimeStamp {
 // GetLastEventLabel returns the localization label for the current note.
 func (note *BoardNote) GetLastEventLabel() string {
 	if note.UpdatedUnix > note.CreatedUnix {
-		return "repo.projects.note.edited_by"
+		return "repo.projects.note.updated_by"
 	}
 	return "repo.projects.note.created_by"
 }
 
 // UpdateBoardNote changes a BoardNote
 func UpdateBoardNote(ctx context.Context, note *BoardNote) error {
-	var fieldToUpdate []string
-
-	if note.Title != "" {
-		fieldToUpdate = append(fieldToUpdate, "title")
-	}
-
-	fieldToUpdate = append(fieldToUpdate, "content")
+	_, err := db.GetEngine(ctx).ID(note.ID).Update(note)
+	return err
+}
 
-	_, err := db.GetEngine(ctx).ID(note.ID).Cols(fieldToUpdate...).Update(note)
+// MoveBoardNoteOnProjectBoard moves or keeps notes in a column and sorts them inside that column
+func MoveBoardNoteOnProjectBoard(ctx context.Context, board *Board, sortedNoteIDs map[int64]int64) error {
+	return db.WithTx(ctx, func(ctx context.Context) error {
+		sess := db.GetEngine(ctx)
 
-	return err
+		for sorting, issueID := range sortedNoteIDs {
+			_, err := sess.Exec("UPDATE `board_note` SET board_id=?, sorting=? WHERE id=?", board.ID, sorting, issueID)
+			if err != nil {
+				return err
+			}
+		}
+		return nil
+	})
 }
 
 // DeleteBoardNote removes the BoardNote from the project board.
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index c2c9ccc410b18..01c6e2c1591dc 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -724,7 +724,7 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 	project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
 	if err != nil {
 		if project_model.IsErrProjectNotExist(err) {
-			ctx.NotFound("", nil)
+			ctx.NotFound("ProjectNotFound", err)
 		} else {
 			ctx.ServerError("GetProjectByID", err)
 		}
@@ -734,7 +734,7 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 	note, err := project_model.GetBoardNoteById(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
 		if project_model.IsErrProjectBoardNoteNotExist(err) {
-			ctx.NotFound("", nil)
+			ctx.NotFound("ProjectBoardNoteNotFound", err)
 		} else {
 			ctx.ServerError("GetBoardNoteById", err)
 		}
@@ -805,6 +805,9 @@ func EditBoardNote(ctx *context.Context) {
 
 func DeleteBoardNote(ctx *context.Context) {
 	_, boardNote := checkProjectBoardNoteChangePermissions(ctx)
+	if ctx.Written() {
+		return
+	}
 
 	if err := project_model.DeleteBoardNote(ctx, boardNote); err != nil {
 		ctx.ServerError("DeleteBoardNote", err)
@@ -815,6 +818,91 @@ func DeleteBoardNote(ctx *context.Context) {
 }
 
 func MoveBoardNote(ctx *context.Context) {
+	if ctx.Doer == nil {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only signed in users are allowed to perform this action.",
+		})
+		return
+	}
+
+	if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only authorized users are allowed to perform this action.",
+		})
+		return
+	}
+
+	project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("ProjectNotFound", err)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return
+	}
+
+	var board *project_model.Board
+
+	if ctx.ParamsInt64(":boardID") == 0 {
+		board = &project_model.Board{
+			ID:        0,
+			ProjectID: project.ID,
+			Title:     ctx.Tr("repo.projects.type.uncategorized"),
+		}
+	} else {
+		board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID"))
+		if err != nil {
+			if project_model.IsErrProjectBoardNotExist(err) {
+				ctx.NotFound("ProjectBoardNotExist", nil)
+			} else {
+				ctx.ServerError("GetProjectBoard", err)
+			}
+			return
+		}
+		if board.ProjectID != project.ID {
+			ctx.NotFound("BoardNotInProject", nil)
+			return
+		}
+	}
+
+	type MovedBoardNotesForm struct {
+		BoardNotes []struct {
+			BoardNoteID int64 `json:"boardNoteID"`
+			Sorting     int64 `json:"sorting"`
+		} `json:"boardNotes"`
+	}
+
+	form := &MovedBoardNotesForm{}
+	if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
+		ctx.ServerError("DecodeMovedBoardNotesForm", err)
+	}
+
+	boardNoteIDs := make([]int64, 0, len(form.BoardNotes))
+	sortedBoardNoteIDs := make(map[int64]int64)
+	for _, boardNote := range form.BoardNotes {
+		boardNoteIDs = append(boardNoteIDs, boardNote.BoardNoteID)
+		sortedBoardNoteIDs[boardNote.Sorting] = boardNote.BoardNoteID
+	}
+	movedBoardNotes, err := project_model.GetBoardNoteByIds(ctx, boardNoteIDs)
+	if err != nil {
+		if project_model.IsErrProjectBoardNoteNotExist(err) {
+			ctx.NotFound("BoardNoteNotExisting", nil)
+		} else {
+			ctx.ServerError("GetBoardNoteByIds", err)
+		}
+		return
+	}
+
+	if len(movedBoardNotes) != len(form.BoardNotes) {
+		ctx.ServerError("some board-notes do not exist", errors.New("some board-notes do not exist"))
+		return
+	}
+
+	if err = project_model.MoveBoardNoteOnProjectBoard(ctx, board, sortedBoardNoteIDs); err != nil {
+		ctx.ServerError("MoveBoardNoteOnProjectBoard", err)
+		return
+	}
 
 	ctx.JSONOK()
 }
diff --git a/routers/web/web.go b/routers/web/web.go
index c2cf620c175f9..1bec85ea05df4 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1344,12 +1344,11 @@ func registerRoutes(m *web.Route) {
 
 						m.Group("/note", func() {
 							m.Post("", web.Bind(forms.BoardNoteForm{}), repo.AddNoteToBoard)
+							m.Post("/move", repo.MoveBoardNote)
 
 							m.Group("/{noteID}", func() {
 								m.Put("", web.Bind(forms.BoardNoteForm{}), repo.EditBoardNote)
 								m.Delete("", repo.DeleteBoardNote)
-
-								m.Post("/move", repo.MoveBoardNote)
 							})
 						})
 					})
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index a951356164052..38ce79ebe24c9 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -69,7 +69,7 @@
 			<div class="ui segment project-column" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
 				<div class="project-column-header">
 					<div class="ui large label project-column-title gt-py-2">
-						<div class="ui small circular grey label project-column-issue-count">
+						<div class="ui small circular grey label project-column-issue-note-count">
 							{{.NumIssuesAndNotes ctx}}
 						</div>
 						{{.Title}}
@@ -194,7 +194,7 @@
 
 				{{ $columnID := .ID }}
 				{{if or $canWriteProject (index $.NotesMap $columnID)}}
-					<div class="ui cards note-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
+					<div class="ui cards note-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
 						{{range (index $.NotesMap $columnID)}}
 							<div class="note-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-note="{{.ID}}">
 								{{template "repo/note" (dict "BoardNote" . "CanWriteProjects" $canWriteProject "Link" (print $.Link "/" $columnID))}}
diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index 3285b151463e4..48de24eec162a 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -64,6 +64,16 @@
   flex: unset;
 }
 
+/* don't show divider, if currently no note-cards are draged and note-cards are empty */
+#project-board:not(:has(.note-cards > .card-ghost)) .project-column > .note-cards:not(:has(> *)) + .divider {
+  display: none;
+}
+
+/* give empty note-cards a min-height, if there is currently a note-card been draged */
+#project-board:has(.note-cards > .card-ghost) .project-column > .note-cards:not(:has(> *)) {
+  min-height: 5%;
+}
+
 .project-column > .divider {
   margin: 5px 0;
 }
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index aa21cfd4074bd..d9baac5a50dec 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -5,10 +5,10 @@ import {createSortable} from '../modules/sortable.js';
 
 const {csrfToken} = window.config;
 
-function updateIssueCount(cards) {
+function updateIssueAndNoteCount(cards) {
   const parent = cards.parentElement;
-  const cnt = parent.getElementsByClassName('issue-card').length;
-  parent.getElementsByClassName('project-column-issue-count')[0].textContent = cnt;
+  const cnt = parent.querySelectorAll('.issue-card, .note-card').length;
+  parent.querySelector('.project-column-issue-note-count').textContent = cnt;
 }
 
 function sendPostRequestAndUnsetDirty(url, form, data) {
@@ -28,8 +28,8 @@ function sendPostRequestAndUnsetDirty(url, form, data) {
 
 function moveIssue({item, from, to, oldIndex}) {
   const columnCards = to.getElementsByClassName('issue-card');
-  updateIssueCount(from);
-  updateIssueCount(to);
+  updateIssueAndNoteCount(from);
+  updateIssueAndNoteCount(to);
 
   const columnSorting = {
     issues: Array.from(columnCards, (card, i) => ({
@@ -52,6 +52,33 @@ function moveIssue({item, from, to, oldIndex}) {
   });
 }
 
+function moveNote({from, to}) {
+  const columnCards = to.getElementsByClassName('note-card');
+  updateIssueAndNoteCount(from);
+  updateIssueAndNoteCount(to);
+
+  const columnSorting = {
+    boardNotes: Array.from(columnCards, (card, i) => ({
+      boardNoteID: parseInt($(card).data('note')),
+      sorting: i,
+    })),
+  };
+
+  $.ajax({
+    url: `${to.getAttribute('data-url')}/move`,
+    data: JSON.stringify(columnSorting),
+    headers: {
+      'X-Csrf-Token': csrfToken,
+    },
+    contentType: 'application/json',
+    type: 'POST',
+    success: () => {
+      // reload, because the relative-time changes from `created` to `updated`
+      window.location.reload();
+    },
+  });
+}
+
 async function initRepoProjectSortable() {
   const els = document.querySelectorAll('#project-board > .board.sortable');
   if (!els.length) return;
@@ -102,8 +129,8 @@ async function initRepoProjectSortable() {
       group: 'note-shared',
       animation: 150,
       ghostClass: 'card-ghost',
-      onAdd: () => { console.error('@TODO') },
-      onUpdate: () => { console.error('@TODO') },
+      onAdd: moveNote,
+      onUpdate: moveNote,
       delayOnTouchOnly: true,
       delay: 500
     });

From 90d0b609b5a03225625b842edd3834352721d41e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sat, 3 Feb 2024 21:21:16 +0100
Subject: [PATCH 04/46] feat(board-notes): set boardID to 0 on when board gets
 deleted

---
 models/project/board.go | 4 ++++
 models/project/note.go  | 6 ++++++
 2 files changed, 10 insertions(+)

diff --git a/models/project/board.go b/models/project/board.go
index d49d59e8ffd59..791f63a41495b 100644
--- a/models/project/board.go
+++ b/models/project/board.go
@@ -200,6 +200,10 @@ func deleteBoardByID(ctx context.Context, boardID int64) error {
 		return err
 	}
 
+	if err = board.removeBoardNotes(ctx); err != nil {
+		return err
+	}
+
 	if _, err := db.GetEngine(ctx).ID(board.ID).NoAutoCondition().Delete(board); err != nil {
 		return err
 	}
diff --git a/models/project/note.go b/models/project/note.go
index 540dc8a949dd6..0e46fdd46a325 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -164,3 +164,9 @@ func DeleteBoardNote(ctx context.Context, boardNote *BoardNote) error {
 	}
 	return nil
 }
+
+// removeBoardNotes sets the boardID to 0 for the board
+func (board *Board) removeBoardNotes(ctx context.Context) error {
+	_, err := db.GetEngine(ctx).Exec("UPDATE `board_note` SET board_id = 0 WHERE board_id = ?", board.ID)
+	return err
+}

From a9924b655191c0c5b065425a9e4f9cd53eb1ec12 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sat, 3 Feb 2024 23:03:17 +0100
Subject: [PATCH 05/46] feat(board-notes): add projectID to BoardNote

---
 models/project/board.go      |  1 +
 models/project/note.go       | 26 +++++++++++++++++---------
 models/project/project.go    |  4 ++++
 routers/web/repo/projects.go | 23 +++++++++--------------
 4 files changed, 31 insertions(+), 23 deletions(-)

diff --git a/models/project/board.go b/models/project/board.go
index 791f63a41495b..2e80cfc6461c3 100644
--- a/models/project/board.go
+++ b/models/project/board.go
@@ -85,6 +85,7 @@ func (b *Board) NumIssues(ctx context.Context) int {
 // NumNotes return counter of all notes assigned to the board
 func (b *Board) NumNotes(ctx context.Context) int {
 	c, err := db.GetEngine(ctx).Table("board_note").
+		Where("project_id=?", b.ProjectID).
 		And("board_id=?", b.ID).
 		GroupBy("id").
 		Cols("id").
diff --git a/models/project/note.go b/models/project/note.go
index 0e46fdd46a325..e3efe47ccd7f8 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -21,6 +21,7 @@ type BoardNote struct {
 	Content string `xorm:"LONGTEXT"`
 	Sorting int64  `xorm:"NOT NULL DEFAULT 0"`
 
+	ProjectID int64            `xorm:"INDEX NOT NULL"`
 	BoardID   int64            `xorm:"INDEX NOT NULL"`
 	CreatorID int64            `xorm:"NOT NULL"`
 	Creator   *user_model.User `xorm:"-"`
@@ -34,7 +35,8 @@ type BoardNoteList = []*BoardNote
 // NotesOptions represents options of an note.
 type NotesOptions struct { //nolint
 	db.Paginator
-	BoardID int64
+	ProjectID int64
+	BoardID   int64
 }
 
 func init() {
@@ -66,11 +68,11 @@ func GetBoardNoteByIds(ctx context.Context, noteIDs []int64) (BoardNoteList, err
 	return notes, nil
 }
 
-// LoadNotesFromBoardList load notes assigned to the boards
-func (p *Project) LoadNotesFromBoardList(ctx context.Context, bs BoardList) (map[int64]BoardNoteList, error) {
+// LoadBoardNotesFromBoardList load notes assigned to the boards
+func (p *Project) LoadBoardNotesFromBoardList(ctx context.Context, bs BoardList) (map[int64]BoardNoteList, error) {
 	notesMap := make(map[int64]BoardNoteList, len(bs))
 	for i := range bs {
-		il, err := LoadNotesFromBoard(ctx, bs[i])
+		il, err := LoadBoardNotesFromBoard(ctx, bs[i])
 		if err != nil {
 			return nil, err
 		}
@@ -79,10 +81,11 @@ func (p *Project) LoadNotesFromBoardList(ctx context.Context, bs BoardList) (map
 	return notesMap, nil
 }
 
-// LoadNotesFromBoard load notes assigned to this board
-func LoadNotesFromBoard(ctx context.Context, board *Board) (BoardNoteList, error) {
+// LoadBoardNotesFromBoard load notes assigned to this board
+func LoadBoardNotesFromBoard(ctx context.Context, board *Board) (BoardNoteList, error) {
 	notes, err := BoardNotes(ctx, &NotesOptions{
-		BoardID: board.ID,
+		ProjectID: board.ProjectID,
+		BoardID:   board.ID,
 	})
 	if err != nil {
 		return nil, err
@@ -95,10 +98,10 @@ func LoadNotesFromBoard(ctx context.Context, board *Board) (BoardNoteList, error
 func BoardNotes(ctx context.Context, opts *NotesOptions) (BoardNoteList, error) {
 	sess := db.GetEngine(ctx)
 
-	sess.Where(builder.Eq{"board_id": max(opts.BoardID, 0)})
+	sess.Where(builder.Eq{"board_id": opts.BoardID}).And(builder.Eq{"project_id": opts.ProjectID})
 
 	notes := BoardNoteList{}
-	if err := sess.OrderBy("Sorting").Find(&notes); err != nil {
+	if err := sess.OrderBy("sorting").Find(&notes); err != nil {
 		return nil, fmt.Errorf("unable to query Notes: %w", err)
 	}
 
@@ -157,6 +160,11 @@ func MoveBoardNoteOnProjectBoard(ctx context.Context, board *Board, sortedNoteID
 	})
 }
 
+func deleteBoardNoteByProjectID(ctx context.Context, projectID int64) error {
+	_, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&BoardNote{})
+	return err
+}
+
 // DeleteBoardNote removes the BoardNote from the project board.
 func DeleteBoardNote(ctx context.Context, boardNote *BoardNote) error {
 	if _, err := db.GetEngine(ctx).Delete(boardNote); err != nil {
diff --git a/models/project/project.go b/models/project/project.go
index eca49f85a0d15..c42e8b47b2bef 100644
--- a/models/project/project.go
+++ b/models/project/project.go
@@ -430,6 +430,10 @@ func DeleteProjectByID(ctx context.Context, id int64) error {
 			return err
 		}
 
+		if err := deleteBoardNoteByProjectID(ctx, id); err != nil {
+			return err
+		}
+
 		if err := deleteBoardByProjectID(ctx, id); err != nil {
 			return err
 		}
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 01c6e2c1591dc..3314fe4f10c0d 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -324,7 +324,7 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	notesMap, err := project.LoadNotesFromBoardList(ctx, boards)
+	notesMap, err := project.LoadBoardNotesFromBoardList(ctx, boards)
 	if err != nil {
 		ctx.ServerError("LoadNotesOfBoards", err)
 		return
@@ -740,10 +740,9 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 		}
 		return nil, nil
 	}
-	boardID := ctx.ParamsInt64(":boardID")
-	if note.BoardID != boardID {
+	if note.ProjectID != project.ID {
 		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
-			"message": fmt.Sprintf("ProjectBoardNote[%d] is not in Board[%d] as expected", note.ID, boardID),
+			"message": fmt.Sprintf("ProjectBoardNote[%d] is not in Project[%d] as expected", note.ID, project.ID),
 		})
 		return nil, nil
 	}
@@ -770,10 +769,11 @@ func AddNoteToBoard(ctx *context.Context) {
 		Title:   form.Title,
 		Content: form.Content,
 
+		ProjectID: ctx.ParamsInt64(":id"),
 		BoardID:   ctx.ParamsInt64(":boardID"),
 		CreatorID: ctx.Doer.ID,
 	}); err != nil {
-		ctx.ServerError("NewProjectBoard", err)
+		ctx.ServerError("NewProjectBoardNote", err)
 		return
 	}
 
@@ -782,20 +782,15 @@ func AddNoteToBoard(ctx *context.Context) {
 
 func EditBoardNote(ctx *context.Context) {
 	form := web.GetForm(ctx).(*forms.BoardNoteForm)
-	_, note := checkProjectBoardNoteChangePermissions(ctx)
+	_, boardNote := checkProjectBoardNoteChangePermissions(ctx)
 	if ctx.Written() {
 		return
 	}
 
-	if form.Title != "" {
-		note.Title = form.Title
-	}
-
-	if form.Content != "" {
-		note.Content = form.Content
-	}
+	boardNote.Title = form.Title
+	boardNote.Content = form.Content
 
-	if err := project_model.UpdateBoardNote(ctx, note); err != nil {
+	if err := project_model.UpdateBoardNote(ctx, boardNote); err != nil {
 		ctx.ServerError("UpdateProjectBoardNote", err)
 		return
 	}

From 423404c04835847f99a31413ab155b94f1636d3d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 00:04:31 +0100
Subject: [PATCH 06/46] feat(board-notes): better styles for large cards

---
 templates/projects/view.tmpl         | 38 +++++++++++++++-------------
 web_src/css/features/projects.css    | 27 ++++++++++++--------
 web_src/js/features/repo-projects.js |  6 ++---
 3 files changed, 39 insertions(+), 32 deletions(-)

diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 38ce79ebe24c9..80376f0c7b4ad 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -192,27 +192,29 @@
 
 				<div class="divider"></div>
 
-				{{ $columnID := .ID }}
-				{{if or $canWriteProject (index $.NotesMap $columnID)}}
-					<div class="ui cards note-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
-						{{range (index $.NotesMap $columnID)}}
-							<div class="note-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-note="{{.ID}}">
-								{{template "repo/note" (dict "BoardNote" . "CanWriteProjects" $canWriteProject "Link" (print $.Link "/" $columnID))}}
+				<div class="cards-wrapper">
+					{{ $columnID := .ID }}
+					{{if or $canWriteProject (index $.NotesMap $columnID)}}
+						<div class="ui cards note-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
+							{{range (index $.NotesMap $columnID)}}
+								<div class="note-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-note="{{.ID}}">
+									{{template "repo/note" (dict "BoardNote" . "CanWriteProjects" $canWriteProject "Link" (print $.Link "/" $columnID))}}
+								</div>
+							{{end}}
+						</div>
+	
+						{{if or $canWriteProject (index $.IssuesMap $columnID)}}
+							<div class="divider"></div>
+						{{end}}
+					{{end}}
+	
+					<div class="ui cards issue-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="issue-board-{{.ID}}">
+						{{range (index $.IssuesMap $columnID)}}
+							<div class="issue-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-issue="{{.ID}}">
+								{{template "repo/issue/card" (dict "Issue" . "Page" $)}}
 							</div>
 						{{end}}
 					</div>
-
-					{{if or $canWriteProject (index $.IssuesMap $columnID)}}
-						<div class="divider"></div>
-					{{end}}
-				{{end}}
-
-				<div class="ui cards issue-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="issue-board-{{.ID}}">
-					{{range (index $.IssuesMap $columnID)}}
-						<div class="issue-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-issue="{{.ID}}">
-							{{template "repo/issue/card" (dict "Issue" . "Page" $)}}
-						</div>
-					{{end}}
 				</div>
 			</div>
 		{{end}}
diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index 48de24eec162a..23ddec8e88b80 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -14,7 +14,6 @@
   width: 320px;
   height: calc(100vh - 450px);
   min-height: 60vh;
-  overflow-y: scroll;
   flex: 0 0 auto;
   overflow: visible;
   display: flex;
@@ -48,29 +47,35 @@
   line-height: 1.25 !important;
 }
 
-.project-column > .cards {
+.cards-wrapper {
   flex: 1;
   display: flex;
-  align-content: baseline;
-  margin: 0 !important;
-  padding: 0 !important;
-  flex-wrap: nowrap !important;
   flex-direction: column;
   overflow-x: auto;
+}
+
+.cards-wrapper > .cards {
+  flex-grow: 1;
+  margin: 0 !important;
+  flex-direction: column;
   gap: .25rem;
 }
 
-.project-column > .note-cards {
-  flex: unset;
+.cards-wrapper > .note-cards {
+  flex-grow: unset;
 }
 
 /* don't show divider, if currently no note-cards are draged and note-cards are empty */
-#project-board:not(:has(.note-cards > .card-ghost)) .project-column > .note-cards:not(:has(> *)) + .divider {
+#project-board:not(:has(.note-cards > .card-ghost)) .cards-wrapper > .note-cards:not(:has(> *)) + .divider,
+/* don't show divider, if there are no childs in issue-cards */
+.project-column:not(:has(.issue-cards > *)) .note-cards + .divider {
   display: none;
 }
 
-/* give empty note-cards a min-height, if there is currently a note-card been draged */
-#project-board:has(.note-cards > .card-ghost) .project-column > .note-cards:not(:has(> *)) {
+/* give empty cards a min-height, if there is currently a child-card been draged */
+#project-board:has(.note-cards > .card-ghost) .cards-wrapper > .note-cards:not(:has(> *)),
+/* set min-height if issue-cards, this helps when note-cards are heigher then 100% */
+#project-board:has(.card-ghost) .cards-wrapper > .issue-cards:not(:has(> *)) {
   min-height: 5%;
 }
 
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index d9baac5a50dec..c22810f5293ac 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -6,9 +6,9 @@ import {createSortable} from '../modules/sortable.js';
 const {csrfToken} = window.config;
 
 function updateIssueAndNoteCount(cards) {
-  const parent = cards.parentElement;
-  const cnt = parent.querySelectorAll('.issue-card, .note-card').length;
-  parent.querySelector('.project-column-issue-note-count').textContent = cnt;
+  const cardsWrapper = $(cards).closest('.cards-wrapper');
+  const cnt = cardsWrapper.find('.issue-card, .note-card').length;
+  cardsWrapper.find('.project-column-issue-note-count').text(cnt);
 }
 
 function sendPostRequestAndUnsetDirty(url, form, data) {

From c3698893a3569f355e80abd004027a2940aac711 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 00:26:25 +0100
Subject: [PATCH 07/46] fix: update note content on frontend and backend

---
 models/project/note.go               |  7 ++++++-
 templates/repo/note.tmpl             |  8 +++-----
 web_src/js/features/repo-projects.js | 15 ++++++++-------
 3 files changed, 17 insertions(+), 13 deletions(-)

diff --git a/models/project/note.go b/models/project/note.go
index e3efe47ccd7f8..17966377d4b69 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -141,7 +141,12 @@ func (note *BoardNote) GetLastEventLabel() string {
 
 // UpdateBoardNote changes a BoardNote
 func UpdateBoardNote(ctx context.Context, note *BoardNote) error {
-	_, err := db.GetEngine(ctx).ID(note.ID).Update(note)
+	var fieldToUpdate []string
+
+	fieldToUpdate = append(fieldToUpdate, "title")
+	fieldToUpdate = append(fieldToUpdate, "content")
+
+	_, err := db.GetEngine(ctx).ID(note.ID).Cols(fieldToUpdate...).Update(note)
 	return err
 }
 
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 85fa7a9aff8fe..b86d9c6cdcdb8 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -60,11 +60,9 @@
 			</div>
 		{{end}}
 	</div>
-	{{if and .Content (gt (len .Content) 0)}}
-		<div class="project-column-note-content">
-			{{.Content}}
-		</div>
-	{{end}}
+	<div class="project-column-note-content">
+		{{.Content}}
+	</div>
 	<div class="meta">
 		<span class="text light grey muted-links">
 			{{$timeStr := TimeSinceUnix .GetLastEventTimestamp ctx.Locale}}
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index c22810f5293ac..efba6686abbc1 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -6,9 +6,9 @@ import {createSortable} from '../modules/sortable.js';
 const {csrfToken} = window.config;
 
 function updateIssueAndNoteCount(cards) {
-  const cardsWrapper = $(cards).closest('.cards-wrapper');
-  const cnt = cardsWrapper.find('.issue-card, .note-card').length;
-  cardsWrapper.find('.project-column-issue-note-count').text(cnt);
+  const column = $(cards).closest('.project-column');
+  const cnt = column.find('.issue-card, .note-card').length;
+  column.find('.project-column-issue-note-count').text(cnt);
 }
 
 function sendPostRequestAndUnsetDirty(url, form, data) {
@@ -72,10 +72,9 @@ function moveNote({from, to}) {
     },
     contentType: 'application/json',
     type: 'POST',
-    success: () => {
-      // reload, because the relative-time changes from `created` to `updated`
-      window.location.reload();
-    },
+  }).done(() => {
+    // reload, because the relative-time changes from `created` to `updated`
+    window.location.reload();
   });
 }
 
@@ -278,6 +277,8 @@ export function initRepoProject() {
         noteContent.text(noteContentTextarea.val());
         editModal.removeClass('dirty');
         $('.ui.modal').modal('hide');
+        // reload, because the relative-time changes from `created` to `updated`
+        window.location.reload();
       });
     });
   });

From 962ea45818856657dee8d67f8938fb06f2c8826d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 00:32:23 +0100
Subject: [PATCH 08/46] enhance: don't reload page to remove deleted column and
 cards

---
 web_src/js/features/repo-projects.js | 22 ++++++++++++++--------
 1 file changed, 14 insertions(+), 8 deletions(-)

diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index efba6686abbc1..2596f374a12d2 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -199,22 +199,25 @@ export function initRepoProject() {
   });
 
   $('.show-delete-project-column-modal').each(function () {
-    const deleteColumnModal = $(`${$(this).attr('data-modal')}`);
-    const deleteColumnButton = deleteColumnModal.find('.actions > .ok.button');
-    const deleteUrl = $(this).attr('data-url');
+    const deleteColumnModalId = $(this).data('modal');
+    const deleteColumnUrl = $(this).data('url');
+
+    const columnToDelete = $(this).closest('.project-column');
+    const deleteColumnModal = $(deleteColumnModalId);
 
+    const deleteColumnButton = deleteColumnModal.find('.actions > .ok.button');
     deleteColumnButton.on('click', (e) => {
       e.preventDefault();
 
       $.ajax({
-        url: deleteUrl,
+        url: deleteColumnUrl,
         headers: {
           'X-Csrf-Token': csrfToken,
         },
         contentType: 'application/json',
         method: 'DELETE',
       }).done(() => {
-        window.location.reload();
+        columnToDelete.remove();
       });
     });
   });
@@ -285,9 +288,10 @@ export function initRepoProject() {
   $('.show-delete-project-column-note-modal').each(function () {
     const deleteNoteUrl = $(this).data('url');
     const modalId = $(this).data('modal');
-    const deletModal = $(modalId);
+    const deleteModal = $(modalId);
+    const noteCard = deleteModal.closest('.note-card');
 
-    const confirmDeleteButton = deletModal.find('.actions > .ok.button');
+    const confirmDeleteButton = deleteModal.find('.actions > .ok.button');
     confirmDeleteButton.on('click', (e) => {
       e.preventDefault();
 
@@ -299,7 +303,9 @@ export function initRepoProject() {
         contentType: 'application/json',
         method: 'DELETE',
       }).done(() => {
-        window.location.reload();
+        const noteCards = noteCard.parent();
+        noteCard.remove();
+        updateIssueAndNoteCount(noteCards);
       });
     });
   });

From ac79a3197f69478aeb9739779f89e989dee79318 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 00:39:34 +0100
Subject: [PATCH 09/46] fix: max-width for cards and attachments (fixes:
 #29029)

---
 web_src/css/features/projects.css | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index 23ddec8e88b80..8c4d82684a9c0 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -59,6 +59,7 @@
   margin: 0 !important;
   flex-direction: column;
   gap: .25rem;
+  max-width: 100%;
 }
 
 .cards-wrapper > .note-cards {
@@ -93,6 +94,7 @@
 
 .card-attachment-images {
   display: inline-block;
+  max-width: 100%;
   white-space: nowrap;
   overflow: hidden;
   text-align: center;
@@ -101,6 +103,7 @@
 .card-attachment-images img {
   display: inline-block;
   max-height: 50px;
+  max-width: 100%;
   border-radius: var(--border-radius);
   margin-right: 2px;
 }

From bff8dc6523e44655a68ab8ee5ecbdadd843b0610 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 00:46:28 +0100
Subject: [PATCH 10/46] enhance: can add notes to 'Uncategorized'-Column

---
 templates/projects/view.tmpl | 138 ++++++++++++++++++-----------------
 1 file changed, 71 insertions(+), 67 deletions(-)

diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 80376f0c7b4ad..85b90c4fde4ca 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -74,7 +74,7 @@
 						</div>
 						{{.Title}}
 					</div>
-					{{if and $canWriteProject (ne .ID 0)}}
+					{{if $canWriteProject}}
 						<div class="ui dropdown jump item">
 							<div class="gt-px-3">
 								{{svg "octicon-kebab-horizontal"}}
@@ -86,35 +86,37 @@
 										{{svg "octicon-note"}}
 										{{ctx.Locale.Tr "repo.projects.note.add"}}
 								</a>
-								<a class="item show-modal button" data-modal="#edit-project-column-modal-{{.ID}}">
-									{{svg "octicon-pencil"}}
-									{{ctx.Locale.Tr "repo.projects.column.edit"}}
-								</a>
-								{{if not .Default}}
-									<a class="item show-modal button default-project-column-show"
-									data-modal="#default-project-column-modal-{{.ID}}"
-									data-modal-default-project-column-header="{{ctx.Locale.Tr "repo.projects.column.set_default"}}"
-									data-modal-default-project-column-content="{{ctx.Locale.Tr "repo.projects.column.set_default_desc"}}"
-									data-url="{{$.Link}}/{{.ID}}/default">
-										{{svg "octicon-pin"}}
-										{{ctx.Locale.Tr "repo.projects.column.set_default"}}
+								{{if (ne .ID 0)}}
+									<a class="item show-modal button" data-modal="#edit-project-column-modal-{{.ID}}">
+										{{svg "octicon-pencil"}}
+										{{ctx.Locale.Tr "repo.projects.column.edit"}}
 									</a>
-								{{else}}
-									<a class="item show-modal button default-project-column-show"
-									data-modal="#default-project-column-modal-{{.ID}}"
-									data-modal-default-project-column-header="{{ctx.Locale.Tr "repo.projects.column.unset_default"}}"
-									data-modal-default-project-column-content="{{ctx.Locale.Tr "repo.projects.column.unset_default_desc"}}"
-									data-url="{{$.Link}}/{{.ID}}/unsetdefault">
-										{{svg "octicon-pin-slash"}}
-										{{ctx.Locale.Tr "repo.projects.column.unset_default"}}
+									{{if not .Default}}
+										<a class="item show-modal button default-project-column-show"
+										data-modal="#default-project-column-modal-{{.ID}}"
+										data-modal-default-project-column-header="{{ctx.Locale.Tr "repo.projects.column.set_default"}}"
+										data-modal-default-project-column-content="{{ctx.Locale.Tr "repo.projects.column.set_default_desc"}}"
+										data-url="{{$.Link}}/{{.ID}}/default">
+											{{svg "octicon-pin"}}
+											{{ctx.Locale.Tr "repo.projects.column.set_default"}}
+										</a>
+									{{else}}
+										<a class="item show-modal button default-project-column-show"
+										data-modal="#default-project-column-modal-{{.ID}}"
+										data-modal-default-project-column-header="{{ctx.Locale.Tr "repo.projects.column.unset_default"}}"
+										data-modal-default-project-column-content="{{ctx.Locale.Tr "repo.projects.column.unset_default_desc"}}"
+										data-url="{{$.Link}}/{{.ID}}/unsetdefault">
+											{{svg "octicon-pin-slash"}}
+											{{ctx.Locale.Tr "repo.projects.column.unset_default"}}
+										</a>
+									{{end}}
+									<a class="item show-modal button show-delete-project-column-modal"
+										data-modal="#delete-project-column-modal-{{.ID}}"
+										data-url="{{$.Link}}/{{.ID}}">
+										{{svg "octicon-trash"}}
+										{{ctx.Locale.Tr "repo.projects.column.delete"}}
 									</a>
 								{{end}}
-								<a class="item show-modal button show-delete-project-column-modal"
-									data-modal="#delete-project-column-modal-{{.ID}}"
-									data-url="{{$.Link}}/{{.ID}}">
-									{{svg "octicon-trash"}}
-									{{ctx.Locale.Tr "repo.projects.column.delete"}}
-								</a>
 
 								<div class="ui small modal" id="add-project-column-note-modal-{{.ID}}">
 									<div class="header">
@@ -137,54 +139,56 @@
 									{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm" "ModalButtonOkText" (ctx.Locale.Tr "repo.projects.note.add"))}}
 								</div>
 
-								<div class="ui small modal edit-project-column-modal" id="edit-project-column-modal-{{.ID}}">
-									<div class="header">
-										{{ctx.Locale.Tr "repo.projects.column.edit"}}
-									</div>
-									<div class="content">
-										<form class="ui form">
-											<div class="required field">
-												<label for="new_project_column_title">{{ctx.Locale.Tr "repo.projects.column.edit_title"}}</label>
-												<input class="project-column-title-input" id="new_project_column_title" name="title" value="{{.Title}}" required>
-											</div>
+								{{if (ne .ID 0)}}
+									<div class="ui small modal edit-project-column-modal" id="edit-project-column-modal-{{.ID}}">
+										<div class="header">
+											{{ctx.Locale.Tr "repo.projects.column.edit"}}
+										</div>
+										<div class="content">
+											<form class="ui form">
+												<div class="required field">
+													<label for="new_project_column_title">{{ctx.Locale.Tr "repo.projects.column.edit_title"}}</label>
+													<input class="project-column-title-input" id="new_project_column_title" name="title" value="{{.Title}}" required>
+												</div>
 
-											<div class="field color-field">
-												<label for="new_project_column_color">{{ctx.Locale.Tr "repo.projects.column.color"}}</label>
-												<div class="color picker column">
-													<input class="color-picker" maxlength="7" placeholder="#c320f6" id="new_project_column_color" name="color" value="{{.Color}}">
-													{{template "repo/issue/label_precolors"}}
+												<div class="field color-field">
+													<label for="new_project_column_color">{{ctx.Locale.Tr "repo.projects.column.color"}}</label>
+													<div class="color picker column">
+														<input class="color-picker" maxlength="7" placeholder="#c320f6" id="new_project_column_color" name="color" value="{{.Color}}">
+														{{template "repo/issue/label_precolors"}}
+													</div>
 												</div>
-											</div>
 
-											<div class="text right actions">
-												<button class="ui cancel button">{{ctx.Locale.Tr "settings.cancel"}}</button>
-												<button data-url="{{$.Link}}/{{.ID}}" class="ui primary button edit-project-column-button">{{ctx.Locale.Tr "repo.projects.column.edit"}}</button>
-											</div>
-										</form>
+												<div class="text right actions">
+													<button class="ui cancel button">{{ctx.Locale.Tr "settings.cancel"}}</button>
+													<button data-url="{{$.Link}}/{{.ID}}" class="ui primary button edit-project-column-button">{{ctx.Locale.Tr "repo.projects.column.edit"}}</button>
+												</div>
+											</form>
+										</div>
 									</div>
-								</div>
 
-								<div class="ui g-modal-confirm modal default-project-column-modal" id="default-project-column-modal-{{.ID}}">
-									<div class="header">
-										<span id="default-project-column-header"></span>
-									</div>
-									<div class="content">
-										<label id="default-project-column-content"></label>
+									<div class="ui g-modal-confirm modal default-project-column-modal" id="default-project-column-modal-{{.ID}}">
+										<div class="header">
+											<span id="default-project-column-header"></span>
+										</div>
+										<div class="content">
+											<label id="default-project-column-content"></label>
+										</div>
+										{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
 									</div>
-									{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
-								</div>
 
-								<div class="ui g-modal-confirm modal" id="delete-project-column-modal-{{.ID}}">
-									<div class="header">
-										{{ctx.Locale.Tr "repo.projects.column.delete"}}
-									</div>
-									<div class="content">
-										<label>
-											{{ctx.Locale.Tr "repo.projects.column.deletion_desc"}}
-										</label>
+									<div class="ui g-modal-confirm modal" id="delete-project-column-modal-{{.ID}}">
+										<div class="header">
+											{{ctx.Locale.Tr "repo.projects.column.delete"}}
+										</div>
+										<div class="content">
+											<label>
+												{{ctx.Locale.Tr "repo.projects.column.deletion_desc"}}
+											</label>
+										</div>
+										{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
 									</div>
-									{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
-								</div>
+								{{end}}
 							</div>
 						</div>
 					{{end}}

From 2c63ee1278fa2b5b6dd4ace427be9d8a9372247f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 00:53:22 +0100
Subject: [PATCH 11/46] enhance: don't reload page after moving note

---
 web_src/js/features/repo-projects.js | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index 2596f374a12d2..e6af2b3477868 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -52,7 +52,7 @@ function moveIssue({item, from, to, oldIndex}) {
   });
 }
 
-function moveNote({from, to}) {
+function moveNote({item, from, to, oldIndex}) {
   const columnCards = to.getElementsByClassName('note-card');
   updateIssueAndNoteCount(from);
   updateIssueAndNoteCount(to);
@@ -72,9 +72,9 @@ function moveNote({from, to}) {
     },
     contentType: 'application/json',
     type: 'POST',
-  }).done(() => {
-    // reload, because the relative-time changes from `created` to `updated`
-    window.location.reload();
+    error: () => {
+      from.insertBefore(item, from.children[oldIndex]);
+    },
   });
 }
 

From 696a632ea6115d98451be3a975bb01be7f4ce3bf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 01:03:03 +0100
Subject: [PATCH 12/46] enhance: new sort-order -> newest BoardNote on top

---
 models/project/note.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/models/project/note.go b/models/project/note.go
index 17966377d4b69..1f991d20237f7 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -101,7 +101,7 @@ func BoardNotes(ctx context.Context, opts *NotesOptions) (BoardNoteList, error)
 	sess.Where(builder.Eq{"board_id": opts.BoardID}).And(builder.Eq{"project_id": opts.ProjectID})
 
 	notes := BoardNoteList{}
-	if err := sess.OrderBy("sorting").Find(&notes); err != nil {
+	if err := sess.Asc("sorting").Desc("updated_unix").Desc("id").Find(&notes); err != nil {
 		return nil, fmt.Errorf("unable to query Notes: %w", err)
 	}
 

From a29422f00370fd5424c573764fabe6737e4ff182 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 02:05:55 +0100
Subject: [PATCH 13/46] refactor: use form-fetch-action instead of own $.ajax

---
 templates/projects/view.tmpl         | 35 ++++++------
 templates/repo/note.tmpl             | 38 +++++++------
 web_src/js/features/repo-projects.js | 80 +---------------------------
 3 files changed, 41 insertions(+), 112 deletions(-)

diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 85b90c4fde4ca..79808bfa90e89 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -80,63 +80,64 @@
 								{{svg "octicon-kebab-horizontal"}}
 							</div>
 							<div class="menu user-menu">
-								<a class="item show-modal button show-add-project-column-note-modal"
+								<button class="ui item show-modal button"
 									data-modal="#add-project-column-note-modal-{{.ID}}"
-									data-url="{{$.Link}}/{{.ID}}/note">
+									data-modal-form.action="{{$.Link}}/{{.ID}}/note">
 										{{svg "octicon-note"}}
 										{{ctx.Locale.Tr "repo.projects.note.add"}}
-								</a>
+								</button>
 								{{if (ne .ID 0)}}
-									<a class="item show-modal button" data-modal="#edit-project-column-modal-{{.ID}}">
+									<button class="ui item show-modal button" data-modal="#edit-project-column-modal-{{.ID}}">
 										{{svg "octicon-pencil"}}
 										{{ctx.Locale.Tr "repo.projects.column.edit"}}
-									</a>
+									</button>
 									{{if not .Default}}
-										<a class="item show-modal button default-project-column-show"
+										<button class="ui item show-modal button default-project-column-show"
 										data-modal="#default-project-column-modal-{{.ID}}"
 										data-modal-default-project-column-header="{{ctx.Locale.Tr "repo.projects.column.set_default"}}"
 										data-modal-default-project-column-content="{{ctx.Locale.Tr "repo.projects.column.set_default_desc"}}"
 										data-url="{{$.Link}}/{{.ID}}/default">
 											{{svg "octicon-pin"}}
 											{{ctx.Locale.Tr "repo.projects.column.set_default"}}
-										</a>
+										</button>
 									{{else}}
-										<a class="item show-modal button default-project-column-show"
+										<button class="ui item show-modal button default-project-column-show"
 										data-modal="#default-project-column-modal-{{.ID}}"
 										data-modal-default-project-column-header="{{ctx.Locale.Tr "repo.projects.column.unset_default"}}"
 										data-modal-default-project-column-content="{{ctx.Locale.Tr "repo.projects.column.unset_default_desc"}}"
 										data-url="{{$.Link}}/{{.ID}}/unsetdefault">
 											{{svg "octicon-pin-slash"}}
 											{{ctx.Locale.Tr "repo.projects.column.unset_default"}}
-										</a>
+										</button>
 									{{end}}
-									<a class="item show-modal button show-delete-project-column-modal"
+									<button class="ui item show-modal button show-delete-project-column-modal"
 										data-modal="#delete-project-column-modal-{{.ID}}"
 										data-url="{{$.Link}}/{{.ID}}">
 										{{svg "octicon-trash"}}
 										{{ctx.Locale.Tr "repo.projects.column.delete"}}
-									</a>
+									</button>
 								{{end}}
 
 								<div class="ui small modal" id="add-project-column-note-modal-{{.ID}}">
 									<div class="header">
 										{{ctx.Locale.Tr "repo.projects.note.add"}}
 									</div>
-									<div class="content">
-										<div class="ui form">
+									<form class="ui form form-fetch-action" method="post">
+										<div class="content">
+											{{$.CsrfTokenHtml}}
 											<div class="required field">
 												<label for="new-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
 												<input id="new-project-column-note-title-{{.ID}}" name="title" maxlength="255" required>
 											</div>
 
-											<div class="field content-field">
+											<div class="field">
 												<label for="new-project-column-note-content-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.content"}}</label>
 												<textarea id="new-project-column-note-content-{{.ID}}" name="content"></textarea>
 											</div>
 										</div>
-									</div>
 
-									{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm" "ModalButtonOkText" (ctx.Locale.Tr "repo.projects.note.add"))}}
+										{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm" "ModalButtonOkText" (ctx.Locale.Tr "repo.projects.note.add"))}}
+									</form>
 								</div>
 
 								{{if (ne .ID 0)}}
@@ -202,7 +203,7 @@
 						<div class="ui cards note-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
 							{{range (index $.NotesMap $columnID)}}
 								<div class="note-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-note="{{.ID}}">
-									{{template "repo/note" (dict "BoardNote" . "CanWriteProjects" $canWriteProject "Link" (print $.Link "/" $columnID))}}
+									{{template "repo/note" (dict "BoardNote" . "CanWriteProjects" $canWriteProject "Link" (print $.Link "/" $columnID) "CsrfTokenHtml" $.CsrfTokenHtml)}}
 								</div>
 							{{end}}
 						</div>
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index b86d9c6cdcdb8..da1c993f1bf72 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -11,50 +11,54 @@
 					{{svg "octicon-kebab-horizontal"}}
 				</div>
 				<div class="menu user-menu left">
-					<a class="item show-modal button show-edit-project-column-note-modal"
+					<button class="ui item show-modal button"
 						data-modal="#edit-project-column-note-modal-{{.ID}}"
-						data-url="{{$.Link}}/note/{{.ID}}">
+						data-modal-form.action="{{$.Link}}/note/{{.ID}}">
 							{{svg "octicon-pencil"}}
 							{{ctx.Locale.Tr "repo.projects.note.edit"}}
-					</a>
-					<a class="item show-modal button show-delete-project-column-note-modal"
+					</button>
+					<button class="ui item show-modal button"
 						data-modal="#delete-project-column-note-modal-{{.ID}}"
-						data-url="{{$.Link}}/note/{{.ID}}">
+						data-modal-form.action="{{$.Link}}/note/{{.ID}}">
 							{{svg "octicon-trash"}}
 							{{ctx.Locale.Tr "repo.projects.note.delete"}}
-					</a>
+					</button>
 
 					<div class="ui small modal" id="edit-project-column-note-modal-{{.ID}}">
 						<div class="header">
 							{{ctx.Locale.Tr "repo.projects.note.edit"}}
 						</div>
-						<div class="content">
-							<div class="ui form">
+						<form class="ui form form-fetch-action" method="put">
+							<div class="content">
+								{{$.CsrfTokenHtml}}
 								<div class="required field">
 									<label for="edit-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
 									<input id="edit-project-column-note-title-{{.ID}}" name="title" value="{{.Title}}" maxlength="255" required>
 								</div>
 	
-								<div class="field content-field">
+								<div class="field">
 									<label for="edit-project-column-note-content-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.content"}}</label>
 									<textarea id="edit-project-column-note-content-{{.ID}}" name="content">{{.Content}}</textarea>
 								</div>
 							</div>
-						</div>
 
-						{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm" "ModalButtonOkText" (ctx.Locale.Tr "repo.projects.note.edit"))}}
+							{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm" "ModalButtonOkText" (ctx.Locale.Tr "repo.projects.note.edit"))}}
+						</form>
 					</div>
 
 					<div class="ui g-modal-confirm modal" id="delete-project-column-note-modal-{{.ID}}">
 						<div class="header">
 							{{ctx.Locale.Tr "repo.projects.note.delete"}}
 						</div>
-						<div class="content">
-							<label>
-								{{ctx.Locale.Tr "repo.projects.note.deletion_desc"}}
-							</label>
-						</div>
-						{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
+						<form class="ui form form-fetch-action" action="delete">
+							{{$.CsrfTokenHtml}}
+							<div class="content">
+								<label>
+									{{ctx.Locale.Tr "repo.projects.note.deletion_desc"}}
+								</label>
+							</div>
+							{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
+						</form>
 					</div>
 				</div>
 			</div>
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index e6af2b3477868..e28029ed5fa65 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -11,7 +11,7 @@ function updateIssueAndNoteCount(cards) {
   column.find('.project-column-issue-note-count').text(cnt);
 }
 
-function sendPostRequestAndUnsetDirty(url, form, data) {
+function createNewColumn(url, form, data) {
   $.ajax({
     url,
     data: JSON.stringify(data),
@@ -231,83 +231,7 @@ export function initRepoProject() {
     }
     const url = $(this).data('url');
     const form = columnTitle.closest('form');
-    sendPostRequestAndUnsetDirty(url, form, {title: columnTitle.val(), color: projectColorInput.val()});
-  });
-
-  $('.show-add-project-column-note-modal').each(function () {
-    const addNoteUrl = $(this).data('url');
-    const modalId = $(this).data('modal');
-    const addModal = $(modalId);
-
-    const confirmAddButton = addModal.find('.actions > .ok.button');
-    confirmAddButton.on('click', (e) => {
-      e.preventDefault();
-      const noteTitleInput = addModal.find('[name="title"]');
-      const contentTextarea = addModal.find('[name="content"]');
-      if (!noteTitleInput.val()) {
-        return;
-      }
-      const form = noteTitleInput.closest('form');
-      sendPostRequestAndUnsetDirty(addNoteUrl, form, {title: noteTitleInput.val(), content: contentTextarea.val()});
-    });
-  });
-  $('.show-edit-project-column-note-modal').each(function () {
-    const editNoteUrl = $(this).data('url');
-    const modalId = $(this).data('modal');
-    const wrapperCard = $(this).closest('.note-card');
-
-    const noteTitle = wrapperCard.find('.project-column-note-title');
-    const noteContent = wrapperCard.find('.project-column-note-content');
-
-    const editModal = wrapperCard.find(modalId);
-    const noteTitleInput = editModal.find('[name="title"]');
-    const noteContentTextarea = editModal.find('[name="content"]');
-
-    const confirmEditButton = editModal.find('.actions > .ok.button');
-    confirmEditButton.on('click', (e) => {
-      e.preventDefault();
-
-      $.ajax({
-        url: editNoteUrl,
-        data: JSON.stringify({title: noteTitleInput.val(), content: noteContentTextarea.val()}),
-        headers: {
-          'X-Csrf-Token': csrfToken,
-        },
-        contentType: 'application/json',
-        method: 'PUT',
-      }).done(() => {
-        noteTitle.text(noteTitleInput.val());
-        noteContent.text(noteContentTextarea.val());
-        editModal.removeClass('dirty');
-        $('.ui.modal').modal('hide');
-        // reload, because the relative-time changes from `created` to `updated`
-        window.location.reload();
-      });
-    });
-  });
-  $('.show-delete-project-column-note-modal').each(function () {
-    const deleteNoteUrl = $(this).data('url');
-    const modalId = $(this).data('modal');
-    const deleteModal = $(modalId);
-    const noteCard = deleteModal.closest('.note-card');
-
-    const confirmDeleteButton = deleteModal.find('.actions > .ok.button');
-    confirmDeleteButton.on('click', (e) => {
-      e.preventDefault();
-
-      $.ajax({
-        url: deleteNoteUrl,
-        headers: {
-          'X-Csrf-Token': csrfToken,
-        },
-        contentType: 'application/json',
-        method: 'DELETE',
-      }).done(() => {
-        const noteCards = noteCard.parent();
-        noteCard.remove();
-        updateIssueAndNoteCount(noteCards);
-      });
-    });
+    createNewColumn(url, form, {title: columnTitle.val(), color: projectColorInput.val()});
   });
 }
 

From 0f4b806946d998808d2277f71001edff30c94ff8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 05:36:14 +0100
Subject: [PATCH 14/46] enhance: content of BoardNotes with MD support

---
 routers/web/repo/projects.go         | 13 +++++++++++++
 templates/projects/view.tmpl         | 21 ++++++++++++++++-----
 templates/repo/note.tmpl             | 21 +++++++++++++--------
 web_src/css/features/projects.css    |  6 ++++--
 web_src/js/features/repo-projects.js | 17 ++++++++++++++++-
 5 files changed, 62 insertions(+), 16 deletions(-)

diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 3314fe4f10c0d..12f17e145e8fd 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -330,6 +330,19 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
+	for _, noteList := range notesMap {
+		for _, note := range noteList {
+			note.Content, err = markdown.RenderString(&markup.RenderContext{
+				Links: markup.Links{
+					Base: ctx.Repo.RepoLink,
+				},
+				Metas:   ctx.Repo.Repository.ComposeMetas(),
+				GitRepo: ctx.Repo.GitRepo,
+				Ctx:     ctx,
+			}, note.Content)
+		}
+	}
+
 	if project.CardType != project_model.CardTypeTextOnly {
 		issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment)
 		for _, issuesList := range issuesMap {
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 79808bfa90e89..e2f6d9fa72f5e 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -126,13 +126,17 @@
 										<div class="content">
 											{{$.CsrfTokenHtml}}
 											<div class="required field">
-												<label for="new-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
-												<input id="new-project-column-note-title-{{.ID}}" name="title" maxlength="255" required>
+												<label for="new-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.new_title"}}</label>
+												<input id="new-project-column-note-title-{{.ID}}" name="title" maxlength="255" autofocus required>
 											</div>
 
 											<div class="field">
-												<label for="new-project-column-note-content-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.content"}}</label>
-												<textarea id="new-project-column-note-content-{{.ID}}" name="content"></textarea>
+												{{template "shared/combomarkdowneditor" (dict
+													"MarkdownPreviewUrl" (print $.RepoLink "/markup")
+													"MarkdownPreviewContext" $.RepoLink
+													"TextareaName" "content"
+													"TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.note.content")
+												)}}
 											</div>
 										</div>
 
@@ -203,7 +207,14 @@
 						<div class="ui cards note-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
 							{{range (index $.NotesMap $columnID)}}
 								<div class="note-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-note="{{.ID}}">
-									{{template "repo/note" (dict "BoardNote" . "CanWriteProjects" $canWriteProject "Link" (print $.Link "/" $columnID) "CsrfTokenHtml" $.CsrfTokenHtml)}}
+									{{template "repo/note" (dict
+										"BoardNote" .
+										"CanWriteProjects" $canWriteProject
+										"Link" $.Link
+										"RepoLink" $.RepoLink
+										"columnID" $columnID
+										"CsrfTokenHtml" $.CsrfTokenHtml)
+									}}
 								</div>
 							{{end}}
 						</div>
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index da1c993f1bf72..7e1c17fd505ec 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -13,18 +13,18 @@
 				<div class="menu user-menu left">
 					<button class="ui item show-modal button"
 						data-modal="#edit-project-column-note-modal-{{.ID}}"
-						data-modal-form.action="{{$.Link}}/note/{{.ID}}">
+						data-modal-form.action="{{$.Link}}/{{$.columnID}}/note/{{.ID}}">
 							{{svg "octicon-pencil"}}
 							{{ctx.Locale.Tr "repo.projects.note.edit"}}
 					</button>
 					<button class="ui item show-modal button"
 						data-modal="#delete-project-column-note-modal-{{.ID}}"
-						data-modal-form.action="{{$.Link}}/note/{{.ID}}">
+						data-modal-form.action="{{$.Link}}/{{$.columnID}}/note/{{.ID}}">
 							{{svg "octicon-trash"}}
 							{{ctx.Locale.Tr "repo.projects.note.delete"}}
 					</button>
 
-					<div class="ui small modal" id="edit-project-column-note-modal-{{.ID}}">
+					<div class="ui modal" id="edit-project-column-note-modal-{{.ID}}">
 						<div class="header">
 							{{ctx.Locale.Tr "repo.projects.note.edit"}}
 						</div>
@@ -33,12 +33,17 @@
 								{{$.CsrfTokenHtml}}
 								<div class="required field">
 									<label for="edit-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
-									<input id="edit-project-column-note-title-{{.ID}}" name="title" value="{{.Title}}" maxlength="255" required>
+									<input id="edit-project-column-note-title-{{.ID}}" name="title" value="{{.Title}}" maxlength="255" autofocus required>
 								</div>
-	
+
 								<div class="field">
-									<label for="edit-project-column-note-content-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.content"}}</label>
-									<textarea id="edit-project-column-note-content-{{.ID}}" name="content">{{.Content}}</textarea>
+									{{template "shared/combomarkdowneditor" (dict
+										"MarkdownPreviewUrl" (print $.RepoLink "/markup")
+										"MarkdownPreviewContext" $.RepoLink
+										"TextareaName" "content"
+										"TextareaContent" .Content
+										"TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.note.content")
+									)}}
 								</div>
 							</div>
 
@@ -65,7 +70,7 @@
 		{{end}}
 	</div>
 	<div class="project-column-note-content">
-		{{.Content}}
+		{{Str2html .Content}}
 	</div>
 	<div class="meta">
 		<span class="text light grey muted-links">
diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index 8c4d82684a9c0..62881988384f2 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -66,6 +66,10 @@
   flex-grow: unset;
 }
 
+.cards-wrapper > .cards * {
+  max-width: 100%;
+}
+
 /* don't show divider, if currently no note-cards are draged and note-cards are empty */
 #project-board:not(:has(.note-cards > .card-ghost)) .cards-wrapper > .note-cards:not(:has(> *)) + .divider,
 /* don't show divider, if there are no childs in issue-cards */
@@ -94,7 +98,6 @@
 
 .card-attachment-images {
   display: inline-block;
-  max-width: 100%;
   white-space: nowrap;
   overflow: hidden;
   text-align: center;
@@ -103,7 +106,6 @@
 .card-attachment-images img {
   display: inline-block;
   max-height: 50px;
-  max-width: 100%;
   border-radius: var(--border-radius);
   margin-right: 2px;
 }
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index e28029ed5fa65..99f51d572ce91 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
 import {useLightTextOnBackground} from '../utils/color.js';
 import tinycolor from 'tinycolor2';
 import {createSortable} from '../modules/sortable.js';
+import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
 
 const {csrfToken} = window.config;
 
@@ -137,10 +138,24 @@ async function initRepoProjectSortable() {
 }
 
 export function initRepoProject() {
-  if (!$('.repository.projects').length) {
+  const mainContent = $('.repository.projects');
+  if (!mainContent.length) {
     return;
   }
 
+  const modalButtons = mainContent.find('.project-column button[data-modal]');
+  modalButtons.each(function() {
+    const modalButton = $(this);
+    const modalId = modalButton.data('modal');
+
+    const markdownEditor = $(`${modalId} .combo-markdown-editor`);
+    if (!markdownEditor.length) return;
+
+    modalButton.one('click', () => {
+      initComboMarkdownEditor(markdownEditor);
+    });
+  });
+
   const _promise = initRepoProjectSortable();
 
   $('.edit-project-column-modal').each(function () {

From 12f8cda0560d2d9b50333759d0b95481112a5230 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 05:45:51 +0100
Subject: [PATCH 15/46] fix: whole project-column has cursor grab

---
 web_src/css/features/projects.css | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index 62881988384f2..7975ec1cf57d0 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -18,6 +18,8 @@
   overflow: visible;
   display: flex;
   flex-direction: column;
+
+  cursor: grab;
 }
 
 .project-column-header {

From 51c8e09826b8741676ca3e515b87bb34ea9715ea Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 06:24:11 +0100
Subject: [PATCH 16/46] enhance: smaller cards and view-modal

---
 models/project/note.go               |   9 ++-
 routers/web/repo/projects.go         |   2 +-
 templates/projects/view.tmpl         |   2 +-
 templates/repo/note.tmpl             | 111 ++++++++++++++++-----------
 web_src/css/features/projects.css    |  10 +++
 web_src/js/features/repo-projects.js |   2 +-
 6 files changed, 84 insertions(+), 52 deletions(-)

diff --git a/models/project/note.go b/models/project/note.go
index 1f991d20237f7..e39c7942bc3f8 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -16,10 +16,11 @@ import (
 
 // BoardNote is used to represent a note on a boards
 type BoardNote struct {
-	ID      int64  `xorm:"pk autoincr"`
-	Title   string `xorm:"TEXT NOT NULL"`
-	Content string `xorm:"LONGTEXT"`
-	Sorting int64  `xorm:"NOT NULL DEFAULT 0"`
+	ID              int64  `xorm:"pk autoincr"`
+	Title           string `xorm:"TEXT NOT NULL"`
+	Content         string `xorm:"LONGTEXT"`
+	RenderedContent string `xorm:"-"`
+	Sorting         int64  `xorm:"NOT NULL DEFAULT 0"`
 
 	ProjectID int64            `xorm:"INDEX NOT NULL"`
 	BoardID   int64            `xorm:"INDEX NOT NULL"`
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 12f17e145e8fd..6693933a5a7ea 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -332,7 +332,7 @@ func ViewProject(ctx *context.Context) {
 
 	for _, noteList := range notesMap {
 		for _, note := range noteList {
-			note.Content, err = markdown.RenderString(&markup.RenderContext{
+			note.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
 				Links: markup.Links{
 					Base: ctx.Repo.RepoLink,
 				},
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index e2f6d9fa72f5e..5f7986d4d752f 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -118,7 +118,7 @@
 									</button>
 								{{end}}
 
-								<div class="ui small modal" id="add-project-column-note-modal-{{.ID}}">
+								<div class="ui modal" id="add-project-column-note-modal-{{.ID}}">
 									<div class="header">
 										{{ctx.Locale.Tr "repo.projects.note.add"}}
 									</div>
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 7e1c17fd505ec..1bb973abf58a5 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -5,72 +5,93 @@
 			{{svg "octicon-note"}}
 		</div>
 		<span class="note-card-title muted project-column-note-title">{{.Title}}</span>
-		{{ if $.CanWriteProjects }}
+		{{if or $.CanWriteProjects (gt (len .RenderedContent) 0)}}
 			<div class="ui dropdown jump item">
 				<div class="gt-px-3">
 					{{svg "octicon-kebab-horizontal"}}
 				</div>
 				<div class="menu user-menu left">
 					<button class="ui item show-modal button"
-						data-modal="#edit-project-column-note-modal-{{.ID}}"
-						data-modal-form.action="{{$.Link}}/{{$.columnID}}/note/{{.ID}}">
-							{{svg "octicon-pencil"}}
-							{{ctx.Locale.Tr "repo.projects.note.edit"}}
-					</button>
-					<button class="ui item show-modal button"
-						data-modal="#delete-project-column-note-modal-{{.ID}}"
-						data-modal-form.action="{{$.Link}}/{{$.columnID}}/note/{{.ID}}">
-							{{svg "octicon-trash"}}
-							{{ctx.Locale.Tr "repo.projects.note.delete"}}
+						data-modal="#view-project-column-note-modal-{{.ID}}">
+							{{svg "octicon-eye"}}
+							{{ctx.Locale.Tr "repo.projects.note.view"}}
 					</button>
+					{{if $.CanWriteProjects}}
+						<button class="ui item show-modal button"
+							data-modal="#edit-project-column-note-modal-{{.ID}}"
+							data-modal-form.action="{{$.Link}}/{{$.columnID}}/note/{{.ID}}">
+								{{svg "octicon-pencil"}}
+								{{ctx.Locale.Tr "repo.projects.note.edit"}}
+						</button>
+						<button class="ui item show-modal button"
+							data-modal="#delete-project-column-note-modal-{{.ID}}"
+							data-modal-form.action="{{$.Link}}/{{$.columnID}}/note/{{.ID}}">
+								{{svg "octicon-trash"}}
+								{{ctx.Locale.Tr "repo.projects.note.delete"}}
+						</button>
+					{{end}}
 
-					<div class="ui modal" id="edit-project-column-note-modal-{{.ID}}">
+					<div class="ui modal" id="view-project-column-note-modal-{{.ID}}">
 						<div class="header">
-							{{ctx.Locale.Tr "repo.projects.note.edit"}}
+							{{ctx.Locale.Tr "repo.projects.note.view"}}
 						</div>
-						<form class="ui form form-fetch-action" method="put">
-							<div class="content">
-								{{$.CsrfTokenHtml}}
-								<div class="required field">
-									<label for="edit-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
-									<input id="edit-project-column-note-title-{{.ID}}" name="title" value="{{.Title}}" maxlength="255" autofocus required>
-								</div>
+						<div class="content">
+							{{Str2html .RenderedContent}}
+						</div>
+						<div class="actions">
+							<button class="ui cancel button">{{ctx.Locale.Tr "repo.projects.close"}}</button>
+						</div>
+					</div>
 
-								<div class="field">
-									{{template "shared/combomarkdowneditor" (dict
-										"MarkdownPreviewUrl" (print $.RepoLink "/markup")
-										"MarkdownPreviewContext" $.RepoLink
-										"TextareaName" "content"
-										"TextareaContent" .Content
-										"TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.note.content")
-									)}}
-								</div>
+					{{if $.CanWriteProjects}}
+						<div class="ui modal" id="edit-project-column-note-modal-{{.ID}}">
+							<div class="header">
+								{{ctx.Locale.Tr "repo.projects.note.edit"}}
 							</div>
+							<form class="ui form form-fetch-action" method="put">
+								<div class="content">
+									{{$.CsrfTokenHtml}}
+									<div class="required field">
+										<label for="edit-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
+										<input id="edit-project-column-note-title-{{.ID}}" name="title" value="{{.Title}}" maxlength="255" autofocus required>
+									</div>
 
-							{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm" "ModalButtonOkText" (ctx.Locale.Tr "repo.projects.note.edit"))}}
-						</form>
-					</div>
+									<div class="field">
+										{{template "shared/combomarkdowneditor" (dict
+											"MarkdownPreviewUrl" (print $.RepoLink "/markup")
+											"MarkdownPreviewContext" $.RepoLink
+											"TextareaName" "content"
+											"TextareaContent" .Content
+											"TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.note.content")
+										)}}
+									</div>
+								</div>
 
-					<div class="ui g-modal-confirm modal" id="delete-project-column-note-modal-{{.ID}}">
-						<div class="header">
-							{{ctx.Locale.Tr "repo.projects.note.delete"}}
+								{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm" "ModalButtonOkText" (ctx.Locale.Tr "repo.projects.note.edit"))}}
+							</form>
 						</div>
-						<form class="ui form form-fetch-action" action="delete">
-							{{$.CsrfTokenHtml}}
-							<div class="content">
-								<label>
-									{{ctx.Locale.Tr "repo.projects.note.deletion_desc"}}
-								</label>
+
+						<div class="ui g-modal-confirm modal" id="delete-project-column-note-modal-{{.ID}}">
+							<div class="header">
+								{{ctx.Locale.Tr "repo.projects.note.delete"}}
 							</div>
-							{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
-						</form>
-					</div>
+							<form class="ui form form-fetch-action" action="delete">
+								{{$.CsrfTokenHtml}}
+								<div class="content">
+									<label>
+										{{ctx.Locale.Tr "repo.projects.note.deletion_desc"}}
+									</label>
+								</div>
+								{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
+							</form>
+						</div>
+					{{end}}
 				</div>
 			</div>
 		{{end}}
 	</div>
 	<div class="project-column-note-content">
-		{{Str2html .Content}}
+		{{Str2html .RenderedContent}}
 	</div>
 	<div class="meta">
 		<span class="text light grey muted-links">
diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index 7975ec1cf57d0..1fa8dd24cec9e 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -1,3 +1,8 @@
+.ui.modal .content {
+  max-height: 85vh;
+  overflow-y: auto;
+}
+
 .board {
   display: flex;
   flex-direction: row;
@@ -98,6 +103,11 @@
   margin-right: auto !important;
 }
 
+.project-column-note-content {
+  max-height: 250px;
+  overflow-y: hidden;
+}
+
 .card-attachment-images {
   display: inline-block;
   white-space: nowrap;
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index 99f51d572ce91..fce0301663c9d 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -152,7 +152,7 @@ export function initRepoProject() {
     if (!markdownEditor.length) return;
 
     modalButton.one('click', () => {
-      initComboMarkdownEditor(markdownEditor);
+      initComboMarkdownEditor(markdownEditor, {easyMDEOptions: {maxHeight: '50vh', minHeight: '50vh'}});
     });
   });
 

From 4e17cf2119950d94664774470c2d6832e7d678c8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 07:52:25 +0100
Subject: [PATCH 17/46] enhance: create issue from board-note

---
 templates/projects/view.tmpl          |  1 +
 templates/repo/note.tmpl              |  4 ++++
 web_src/js/features/repo-issue-new.js | 28 +++++++++++++++++++++++++++
 web_src/js/features/repo-projects.js  | 12 ++++++++++++
 web_src/js/index.js                   |  2 ++
 5 files changed, 47 insertions(+)
 create mode 100644 web_src/js/features/repo-issue-new.js

diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 5f7986d4d752f..afdc49a23d984 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -213,6 +213,7 @@
 										"Link" $.Link
 										"RepoLink" $.RepoLink
 										"columnID" $columnID
+										"ProjectID" $.Project.ID
 										"CsrfTokenHtml" $.CsrfTokenHtml)
 									}}
 								</div>
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 1bb973abf58a5..fbafb78920abf 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -23,6 +23,10 @@
 								{{svg "octicon-pencil"}}
 								{{ctx.Locale.Tr "repo.projects.note.edit"}}
 						</button>
+						<a class="ui item button create-project-issue-from-note" href="{{$.RepoLink}}/issues/new/choose?project={{$.ProjectID}}">
+							{{svg "octicon-issue-opened"}}
+							{{ctx.Locale.Tr "repo.issues.new"}}
+						</a>
 						<button class="ui item show-modal button"
 							data-modal="#delete-project-column-note-modal-{{.ID}}"
 							data-modal-form.action="{{$.Link}}/{{$.columnID}}/note/{{.ID}}">
diff --git a/web_src/js/features/repo-issue-new.js b/web_src/js/features/repo-issue-new.js
new file mode 100644
index 0000000000000..5aaa2bba21448
--- /dev/null
+++ b/web_src/js/features/repo-issue-new.js
@@ -0,0 +1,28 @@
+import $ from 'jquery';
+import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
+
+export function initRepoIssueNew() {
+  const newIssuePage = $('.repository.issue.new');
+  if (!newIssuePage.length) return;
+
+  // this is set from board-notes in repo-projects.js
+  const boardNoteTitle = sessionStorage.getItem('board-note-title');
+  const boardNoteContent = sessionStorage.getItem('board-note-content');
+
+  if (boardNoteTitle) {
+    const issueTitle = newIssuePage.find('#issue_title');
+    issueTitle.val(boardNoteTitle);
+  }
+  if (boardNoteContent) {
+    // @TODO: find a better way to get the combobox
+    const waitForComboMarkdownEditorInterval = setInterval(() => {
+      const comboMarkdownEditorContainer = newIssuePage.find('.combo-markdown-editor');
+      const comboMarkdownEditor = getComboMarkdownEditor(comboMarkdownEditorContainer);
+      if (!comboMarkdownEditor) return;
+
+      clearInterval(waitForComboMarkdownEditorInterval);
+
+      comboMarkdownEditor.value(boardNoteContent);
+    }, 100);
+  }
+}
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index fce0301663c9d..c8b68644142a4 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -158,6 +158,18 @@ export function initRepoProject() {
 
   const _promise = initRepoProjectSortable();
 
+  $('.create-project-issue-from-note').each(function () {
+    const anchorLink = $(this);
+    const wrapperCard = anchorLink.closest('.note-card');
+    const titleInput = wrapperCard.find('[name="title"]');
+    const contentTextarea = wrapperCard.find('.markdown-text-editor');
+
+    anchorLink.on('click', () => {
+      sessionStorage.setItem('board-note-title', titleInput.val());
+      sessionStorage.setItem('board-note-content', contentTextarea.val());
+    });
+  });
+
   $('.edit-project-column-modal').each(function () {
     const projectHeader = $(this).closest('.project-column-header');
     const projectTitleLabel = projectHeader.find('.project-column-title');
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 4713618506b0c..c20f5f0429bbc 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -18,6 +18,7 @@ import {initAdminConfigs} from './features/admin/config.js';
 import {initMarkupAnchors} from './markup/anchors.js';
 import {initNotificationCount, initNotificationsTable} from './features/notification.js';
 import {initRepoIssueContentHistory} from './features/repo-issue-content.js';
+import {initRepoIssueNew} from './features/repo-issue-new.js';
 import {initStopwatch} from './features/stopwatch.js';
 import {initFindFileInRepo} from './features/repo-findfile.js';
 import {initCommentContent, initMarkupContent} from './markup/content.js';
@@ -149,6 +150,7 @@ onDomReady(() => {
   initRepoEditor();
   initRepoGraphGit();
   initRepoIssueContentHistory();
+  initRepoIssueNew();
   initRepoIssueDue();
   initRepoIssueList();
   initRepoIssueSidebarList();

From 0d8f8a21753d917474679e65a44f6e92f3d5000b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 08:41:39 +0100
Subject: [PATCH 18/46] i18n: translations for board-notes

---
 options/locale/locale_de-DE.ini | 13 ++++++++++++-
 options/locale/locale_en-US.ini | 13 ++++++++++++-
 templates/projects/view.tmpl    |  6 +++---
 3 files changed, 27 insertions(+), 5 deletions(-)

diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini
index c24d25b1ac6f2..8acece6e79f6e 100644
--- a/options/locale/locale_de-DE.ini
+++ b/options/locale/locale_de-DE.ini
@@ -1327,7 +1327,7 @@ projects.column.set_default_desc=Diese Spalte als Standard für unkategorisierte
 projects.column.unset_default=Standard entfernen
 projects.column.unset_default_desc=Diese Spalte als Standard entfernen
 projects.column.delete=Spalte löschen
-projects.column.deletion_desc=Beim Löschen einer Projektspalte werden alle dazugehörigen Issues nach 'Nicht kategorisiert' verschoben. Fortfahren?
+projects.column.deletion_desc=Beim Löschen einer Projektspalte werden alle dazugehörigen Issues und Notizen nach 'Nicht kategorisiert' verschoben. Fortfahren?
 projects.column.color=Farbe
 projects.open=Öffnen
 projects.close=Schließen
@@ -1336,6 +1336,17 @@ projects.card_type.desc=Kartenvorschau
 projects.card_type.images_and_text=Bilder und Text
 projects.card_type.text_only=Nur Text
 
+projects.note.view = Notiz ansehen
+projects.note.new = Neue Notiz
+projects.note.new_title = Titel
+projects.note.content = Schreibe eine Notiz
+projects.note.edit = Notiz bearbeiten
+projects.note.edit_title = Titel
+projects.note.delete = Notiz löschen
+projects.note.deletion_desc = Beim Löschen einer Notiz wird diese von der Projektspalte entfernt. Fortfahren?
+projects.note.created_by = erstellt %[1]s von <a href="%[2]s">%[3]s</a>
+projects.note.updated_by = bearbeitet %[1]s von <a href="%[2]s">%[3]s</a>
+
 issues.desc=Verwalte Bug-Reports, Aufgaben und Meilensteine.
 issues.filter_assignees=Filter
 issues.filter_milestones=Meilenstein filtern
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 9af4d70171c50..f0d1089ddcfd0 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1343,7 +1343,7 @@ projects.column.set_default_desc = "Set this column as default for uncategorized
 projects.column.unset_default = "Unset Default"
 projects.column.unset_default_desc = "Unset this column as default"
 projects.column.delete = "Delete Column"
-projects.column.deletion_desc = "Deleting a project column moves all related issues to 'Uncategorized'. Continue?"
+projects.column.deletion_desc = "Deleting a project column moves all related issues and notes to 'Uncategorized'. Continue?"
 projects.column.color = "Color"
 projects.open = Open
 projects.close = Close
@@ -1352,6 +1352,17 @@ projects.card_type.desc = "Card Previews"
 projects.card_type.images_and_text = "Images and Text"
 projects.card_type.text_only = "Text Only"
 
+projects.note.view = View Content
+projects.note.new = New Note
+projects.note.new_title = Title
+projects.note.content = Write a Note
+projects.note.edit = Edit Note
+projects.note.edit_title = Title
+projects.note.delete = Delete Note
+projects.note.deletion_desc = Deleting removes the note from the project column. Continue?
+projects.note.created_by = created %[1]s by <a href="%[2]s">%[3]s</a>
+projects.note.updated_by = updated %[1]s by <a href="%[2]s">%[3]s</a>
+
 issues.desc = Organize bug reports, tasks and milestones.
 issues.filter_assignees = Filter Assignee
 issues.filter_milestones = Filter Milestone
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index afdc49a23d984..62c0346c56b84 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -84,7 +84,7 @@
 									data-modal="#add-project-column-note-modal-{{.ID}}"
 									data-modal-form.action="{{$.Link}}/{{.ID}}/note">
 										{{svg "octicon-note"}}
-										{{ctx.Locale.Tr "repo.projects.note.add"}}
+										{{ctx.Locale.Tr "repo.projects.note.new"}}
 								</button>
 								{{if (ne .ID 0)}}
 									<button class="ui item show-modal button" data-modal="#edit-project-column-modal-{{.ID}}">
@@ -120,7 +120,7 @@
 
 								<div class="ui modal" id="add-project-column-note-modal-{{.ID}}">
 									<div class="header">
-										{{ctx.Locale.Tr "repo.projects.note.add"}}
+										{{ctx.Locale.Tr "repo.projects.note.new"}}
 									</div>
 									<form class="ui form form-fetch-action" method="post">
 										<div class="content">
@@ -140,7 +140,7 @@
 											</div>
 										</div>
 
-										{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm" "ModalButtonOkText" (ctx.Locale.Tr "repo.projects.note.add"))}}
+										{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm" "ModalButtonOkText" (ctx.Locale.Tr "repo.projects.note.new"))}}
 									</form>
 								</div>
 

From 965f70d9830dae8680dff55c999ab74698be0b35 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 08:06:04 +0100
Subject: [PATCH 19/46] fix: edit the column-title don't remove the count-div
 (fixes: #29031)

---
 web_src/js/features/repo-projects.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index c8b68644142a4..564b734928869 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -193,7 +193,7 @@ export function initRepoProject() {
         contentType: 'application/json',
         method: 'PUT',
       }).done(() => {
-        projectTitleLabel.text(projectTitleInput.val());
+        projectTitleLabel.contents().last().replaceWith(projectTitleInput.val());
         projectTitleInput.closest('form').removeClass('dirty');
         if (projectColorInput.val()) {
           setLabelColor(projectHeader, projectColorInput.val());

From 762f2cec2ff78d42e82b7ab346f064b8d60cd3b2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 09:05:59 +0100
Subject: [PATCH 20/46] fix: v1.21 to main

---
 routers/web/repo/projects.go      | 2 +-
 templates/projects/view.tmpl      | 2 +-
 templates/repo/note.tmpl          | 2 +-
 web_src/css/features/projects.css | 1 +
 4 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 6693933a5a7ea..79fd826756d5f 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -336,7 +336,7 @@ func ViewProject(ctx *context.Context) {
 				Links: markup.Links{
 					Base: ctx.Repo.RepoLink,
 				},
-				Metas:   ctx.Repo.Repository.ComposeMetas(),
+				Metas:   ctx.Repo.Repository.ComposeMetas(ctx),
 				GitRepo: ctx.Repo.GitRepo,
 				Ctx:     ctx,
 			}, note.Content)
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 62c0346c56b84..ed33d45f60434 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -202,7 +202,7 @@
 				<div class="divider"></div>
 
 				<div class="cards-wrapper">
-					{{ $columnID := .ID }}
+					{{$columnID := .ID}}
 					{{if or $canWriteProject (index $.NotesMap $columnID)}}
 						<div class="ui cards note-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
 							{{range (index $.NotesMap $columnID)}}
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index fbafb78920abf..50dd89c8836a6 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -1,5 +1,5 @@
 {{with .BoardNote}}
-<div class="content gt-p-0 gt-w-100">
+<div class="content gt-p-0 gt-w-full">
 	<div class="gt-df gt-items-start">
 		<div class="project-column-note-card-icon">
 			{{svg "octicon-note"}}
diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index 1fa8dd24cec9e..814334e95b71d 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -50,6 +50,7 @@
 }
 
 .project-column-title {
+  white-space: unset !important;
   background: none !important;
   line-height: 1.25 !important;
 }

From 76b90eba7a7788c90ee8173c63f4e50cb931788b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 09:17:16 +0100
Subject: [PATCH 21/46] chore: lint

---
 models/project/note.go       | 10 +++++-----
 routers/web/repo/projects.go |  6 +++++-
 routers/web/web.go           |  4 ++--
 templates/projects/view.tmpl |  2 --
 templates/repo/note.tmpl     |  2 +-
 5 files changed, 13 insertions(+), 11 deletions(-)

diff --git a/models/project/note.go b/models/project/note.go
index e39c7942bc3f8..dff21ac88857f 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -34,7 +34,7 @@ type BoardNote struct {
 type BoardNoteList = []*BoardNote
 
 // NotesOptions represents options of an note.
-type NotesOptions struct { //nolint
+type NotesOptions struct {
 	db.Paginator
 	ProjectID int64
 	BoardID   int64
@@ -44,8 +44,8 @@ func init() {
 	db.RegisterModel(new(BoardNote))
 }
 
-// GetBoardNoteById load notes assigned to the boards
-func GetBoardNoteById(ctx context.Context, noteID int64) (*BoardNote, error) {
+// GetBoardNoteByID load notes assigned to the boards
+func GetBoardNoteByID(ctx context.Context, noteID int64) (*BoardNote, error) {
 	note := new(BoardNote)
 
 	has, err := db.GetEngine(ctx).ID(noteID).Get(note)
@@ -180,7 +180,7 @@ func DeleteBoardNote(ctx context.Context, boardNote *BoardNote) error {
 }
 
 // removeBoardNotes sets the boardID to 0 for the board
-func (board *Board) removeBoardNotes(ctx context.Context) error {
-	_, err := db.GetEngine(ctx).Exec("UPDATE `board_note` SET board_id = 0 WHERE board_id = ?", board.ID)
+func (b *Board) removeBoardNotes(ctx context.Context) error {
+	_, err := db.GetEngine(ctx).Exec("UPDATE `board_note` SET board_id = 0 WHERE board_id = ?", b.ID)
 	return err
 }
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 79fd826756d5f..a3dc4b42b00dc 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -340,6 +340,10 @@ func ViewProject(ctx *context.Context) {
 				GitRepo: ctx.Repo.GitRepo,
 				Ctx:     ctx,
 			}, note.Content)
+			if err != nil {
+				ctx.ServerError("RenderString", err)
+				return
+			}
 		}
 	}
 
@@ -744,7 +748,7 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 		return nil, nil
 	}
 
-	note, err := project_model.GetBoardNoteById(ctx, ctx.ParamsInt64(":noteID"))
+	note, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
 		if project_model.IsErrProjectBoardNoteNotExist(err) {
 			ctx.NotFound("ProjectBoardNoteNotFound", err)
diff --git a/routers/web/web.go b/routers/web/web.go
index 1bec85ea05df4..b4709d55bd29f 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -985,7 +985,7 @@ func registerRoutes(m *web.Route) {
 				m.Get("", org.Projects)
 				m.Get("/{id}", org.ViewProject)
 			}, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true))
-			m.Group("", func() { //nolint:dupl
+			m.Group("", func() {
 				m.Get("/new", org.RenderNewProject)
 				m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
 				m.Group("/{id}", func() {
@@ -1323,7 +1323,7 @@ func registerRoutes(m *web.Route) {
 		m.Group("/projects", func() {
 			m.Get("", repo.Projects)
 			m.Get("/{id}", repo.ViewProject)
-			m.Group("", func() { //nolint:dupl
+			m.Group("", func() {
 				m.Get("/new", repo.RenderNewProject)
 				m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)
 				m.Group("/{id}", func() {
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index ed33d45f60434..db6e7a477850b 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -219,12 +219,10 @@
 								</div>
 							{{end}}
 						</div>
-	
 						{{if or $canWriteProject (index $.IssuesMap $columnID)}}
 							<div class="divider"></div>
 						{{end}}
 					{{end}}
-	
 					<div class="ui cards issue-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="issue-board-{{.ID}}">
 						{{range (index $.IssuesMap $columnID)}}
 							<div class="issue-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-issue="{{.ID}}">
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 50dd89c8836a6..dd757ab853884 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -104,4 +104,4 @@
 		</span>
 	</div>
 </div>
-{{end}}
\ No newline at end of file
+{{end}}

From 4815e7a8043eb8e5633bc077a22f287d65643ff1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 10:04:55 +0100
Subject: [PATCH 22/46] fix: required changes from CI and Bot

---
 options/locale/locale_de-DE.ini   | 13 +------------
 web_src/css/features/projects.css |  2 +-
 2 files changed, 2 insertions(+), 13 deletions(-)

diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini
index 8acece6e79f6e..c24d25b1ac6f2 100644
--- a/options/locale/locale_de-DE.ini
+++ b/options/locale/locale_de-DE.ini
@@ -1327,7 +1327,7 @@ projects.column.set_default_desc=Diese Spalte als Standard für unkategorisierte
 projects.column.unset_default=Standard entfernen
 projects.column.unset_default_desc=Diese Spalte als Standard entfernen
 projects.column.delete=Spalte löschen
-projects.column.deletion_desc=Beim Löschen einer Projektspalte werden alle dazugehörigen Issues und Notizen nach 'Nicht kategorisiert' verschoben. Fortfahren?
+projects.column.deletion_desc=Beim Löschen einer Projektspalte werden alle dazugehörigen Issues nach 'Nicht kategorisiert' verschoben. Fortfahren?
 projects.column.color=Farbe
 projects.open=Öffnen
 projects.close=Schließen
@@ -1336,17 +1336,6 @@ projects.card_type.desc=Kartenvorschau
 projects.card_type.images_and_text=Bilder und Text
 projects.card_type.text_only=Nur Text
 
-projects.note.view = Notiz ansehen
-projects.note.new = Neue Notiz
-projects.note.new_title = Titel
-projects.note.content = Schreibe eine Notiz
-projects.note.edit = Notiz bearbeiten
-projects.note.edit_title = Titel
-projects.note.delete = Notiz löschen
-projects.note.deletion_desc = Beim Löschen einer Notiz wird diese von der Projektspalte entfernt. Fortfahren?
-projects.note.created_by = erstellt %[1]s von <a href="%[2]s">%[3]s</a>
-projects.note.updated_by = bearbeitet %[1]s von <a href="%[2]s">%[3]s</a>
-
 issues.desc=Verwalte Bug-Reports, Aufgaben und Meilensteine.
 issues.filter_assignees=Filter
 issues.filter_milestones=Meilenstein filtern
diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index 814334e95b71d..7974dc714d99d 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -87,7 +87,7 @@
 
 /* give empty cards a min-height, if there is currently a child-card been draged */
 #project-board:has(.note-cards > .card-ghost) .cards-wrapper > .note-cards:not(:has(> *)),
-/* set min-height if issue-cards, this helps when note-cards are heigher then 100% */
+/* set min-height if issue-cards, this helps when note-cards are higher then 100% */
 #project-board:has(.card-ghost) .cards-wrapper > .issue-cards:not(:has(> *)) {
   min-height: 5%;
 }

From 410b286871cd9629bd4974cd1652b2e65a60c698 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 13:34:09 +0100
Subject: [PATCH 23/46] enhance: show amount of tasks in board-notes card

---
 models/issues/issue.go              | 11 +++--------
 models/project/note.go              | 11 +++++++++++
 modules/markup/markdown/markdown.go |  6 ++++++
 templates/repo/note.tmpl            |  7 +++++++
 4 files changed, 27 insertions(+), 8 deletions(-)

diff --git a/models/issues/issue.go b/models/issues/issue.go
index 90aad10bb9001..74debee7031e5 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -7,7 +7,6 @@ package issues
 import (
 	"context"
 	"fmt"
-	"regexp"
 	"slices"
 
 	"code.gitea.io/gitea/models/db"
@@ -16,6 +15,7 @@ import (
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/container"
 	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/markup/markdown"
 	"code.gitea.io/gitea/modules/setting"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/timeutil"
@@ -141,11 +141,6 @@ type Issue struct {
 	ShowRole RoleDescriptor `xorm:"-"`
 }
 
-var (
-	issueTasksPat     = regexp.MustCompile(`(^\s*[-*]\s\[[\sxX]\]\s.)|(\n\s*[-*]\s\[[\sxX]\]\s.)`)
-	issueTasksDonePat = regexp.MustCompile(`(^\s*[-*]\s\[[xX]\]\s.)|(\n\s*[-*]\s\[[xX]\]\s.)`)
-)
-
 // IssueIndex represents the issue index table
 type IssueIndex db.ResourceIndex
 
@@ -443,12 +438,12 @@ func (issue *Issue) IsPoster(uid int64) bool {
 
 // GetTasks returns the amount of tasks in the issues content
 func (issue *Issue) GetTasks() int {
-	return len(issueTasksPat.FindAllStringIndex(issue.Content, -1))
+	return len(markdown.MarkdownTasksRegex.FindAllStringIndex(issue.Content, -1))
 }
 
 // GetTasksDone returns the amount of completed tasks in the issues content
 func (issue *Issue) GetTasksDone() int {
-	return len(issueTasksDonePat.FindAllStringIndex(issue.Content, -1))
+	return len(markdown.MarkdownTasksDoneRegex.FindAllStringIndex(issue.Content, -1))
 }
 
 // GetLastEventTimestamp returns the last user visible event timestamp, either the creation of this issue or the close.
diff --git a/models/project/note.go b/models/project/note.go
index dff21ac88857f..5d6d1f20f8a9d 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -9,6 +9,7 @@ import (
 
 	"code.gitea.io/gitea/models/db"
 	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/markup/markdown"
 	"code.gitea.io/gitea/modules/timeutil"
 
 	"xorm.io/builder"
@@ -140,6 +141,16 @@ func (note *BoardNote) GetLastEventLabel() string {
 	return "repo.projects.note.created_by"
 }
 
+// GetTasks returns the amount of tasks in the board-notes content
+func (note *BoardNote) GetTasks() int {
+	return len(markdown.MarkdownTasksRegex.FindAllStringIndex(note.Content, -1))
+}
+
+// GetTasksDone returns the amount of completed tasks in the board-notes content
+func (note *BoardNote) GetTasksDone() int {
+	return len(markdown.MarkdownTasksDoneRegex.FindAllStringIndex(note.Content, -1))
+}
+
 // UpdateBoardNote changes a BoardNote
 func UpdateBoardNote(ctx context.Context, note *BoardNote) error {
 	var fieldToUpdate []string
diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go
index 771162b9a3f12..f2732650efc0b 100644
--- a/modules/markup/markdown/markdown.go
+++ b/modules/markup/markdown/markdown.go
@@ -7,6 +7,7 @@ package markdown
 import (
 	"fmt"
 	"io"
+	"regexp"
 	"strings"
 	"sync"
 
@@ -38,6 +39,11 @@ var (
 	renderConfigKey  = parser.NewContextKey()
 )
 
+var (
+	MarkdownTasksRegex     = regexp.MustCompile(`(^\s*[-*]\s\[[\sxX]\]\s.)|(\n\s*[-*]\s\[[\sxX]\]\s.)`)
+	MarkdownTasksDoneRegex = regexp.MustCompile(`(^\s*[-*]\s\[[xX]\]\s.)|(\n\s*[-*]\s\[[xX]\]\s.)`)
+)
+
 type limitWriter struct {
 	w     io.Writer
 	sum   int64
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index dd757ab853884..bb41c7930e10c 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -103,5 +103,12 @@
 			{{ctx.Locale.Tr .GetLastEventLabel $timeStr (.Creator.HomeLink | Escape) (.Creator.GetDisplayName | Escape) | Safe}}
 		</span>
 	</div>
+	{{$tasks := .GetTasks}}
+	{{if gt $tasks 0}}
+		<div class="meta">
+			{{svg "octicon-checklist" 16 "gt-mr-2 gt-vm"}}
+			<span class="gt-vm">{{.GetTasksDone}} / {{$tasks}}</span>
+		</div>
+	{{end}}
 </div>
 {{end}}

From 50841341a3225452c72f600095364a624f6834f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Sun, 4 Feb 2024 21:45:08 +0100
Subject: [PATCH 24/46] enhance: board-notes can now be pinned

---
 models/project/note.go               | 175 ++++++++++++++++++++++++++-
 modules/setting/repository.go        |  10 ++
 options/locale/locale_en-US.ini      |   1 +
 routers/web/repo/projects.go         |  90 ++++++++++++++
 routers/web/web.go                   |   6 +
 templates/projects/view.tmpl         |  33 +++--
 templates/repo/note.tmpl             |  18 ++-
 web_src/css/features/projects.css    |   9 +-
 web_src/js/features/repo-projects.js |  52 ++++++++
 9 files changed, 380 insertions(+), 14 deletions(-)

diff --git a/models/project/note.go b/models/project/note.go
index 5d6d1f20f8a9d..4825d7bc04c4a 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -10,7 +10,9 @@ import (
 	"code.gitea.io/gitea/models/db"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/markup/markdown"
+	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
+	"code.gitea.io/gitea/modules/util"
 
 	"xorm.io/builder"
 )
@@ -22,6 +24,7 @@ type BoardNote struct {
 	Content         string `xorm:"LONGTEXT"`
 	RenderedContent string `xorm:"-"`
 	Sorting         int64  `xorm:"NOT NULL DEFAULT 0"`
+	PinOrder        int64  `xorm:"NOT NULL DEFAULT 0"`
 
 	ProjectID int64            `xorm:"INDEX NOT NULL"`
 	BoardID   int64            `xorm:"INDEX NOT NULL"`
@@ -107,6 +110,7 @@ func BoardNotes(ctx context.Context, opts *NotesOptions) (BoardNoteList, error)
 		return nil, fmt.Errorf("unable to query Notes: %w", err)
 	}
 
+	// @TODO: same code in `GetPinnedBoardNotes()` and should be used with `LoadAttributes()`
 	for _, note := range notes {
 		creator := new(user_model.User)
 		has, err := db.GetEngine(ctx).ID(note.CreatorID).Get(creator)
@@ -128,7 +132,176 @@ func NewBoardNote(ctx context.Context, note *BoardNote) error {
 	return err
 }
 
-// GetLastEventTimestamp returns the last user visible event timestamp, either the creation of this issue or the close.
+/* @TODO: make it work - markdown.RenderString should also be at this function
+// IsPinned returns if a BoardNote is pinned
+func (notes BoardNoteList) LoadAttributes() error {
+	return nil
+} */
+
+var ErrBoardNoteMaxPinReached = util.NewInvalidArgumentErrorf("the max number of pinned board-notes has been readched")
+
+// IsPinned returns if a BoardNote is pinned
+func (note *BoardNote) IsPinned() bool {
+	return note.PinOrder != 0
+}
+
+// IsPinned returns if a BoardNote is pinned
+func (note *BoardNote) GetMaxPinOrder(ctx context.Context) (int64, error) {
+	var maxPin int64
+	_, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM board_note WHERE project_id = ?", note.ProjectID).Get(&maxPin)
+	if err != nil {
+		return -1, err
+	}
+	return maxPin, nil
+}
+
+// IsPinned returns if a BoardNote is pinned
+func (note *BoardNote) IsNewPinAllowed(ctx context.Context) bool {
+	maxPin, err := note.GetMaxPinOrder(ctx)
+	if err != nil {
+		return false
+	}
+
+	// Check if the maximum allowed Pins reached
+	return maxPin < setting.Repository.Project.MaxPinned
+}
+
+// Pin pins a BoardNote
+func (note *BoardNote) Pin(ctx context.Context) error {
+	// If the BoardNote is already pinned, we don't need to pin it twice
+	if note.IsPinned() {
+		return nil
+	}
+
+	maxPin, err := note.GetMaxPinOrder(ctx)
+	if err != nil {
+		return err
+	}
+
+	// Check if the maximum allowed Pins reached
+	if maxPin >= setting.Repository.Project.MaxPinned {
+		return ErrBoardNoteMaxPinReached
+	}
+
+	_, err = db.GetEngine(ctx).Table("board_note").
+		Where("id = ?", note.ID).
+		Update(map[string]any{
+			"pin_order": maxPin + 1,
+		})
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// Unpin unpins a BoardNote
+func (note *BoardNote) Unpin(ctx context.Context) error {
+	// If the BoardNote is not pinned, we don't need to unpin it
+	if !note.IsPinned() {
+		return nil
+	}
+
+	// This sets the Pin for all BoardNotes that come after the unpined BoardNote to the correct value
+	_, err := db.GetEngine(ctx).Exec("UPDATE board_note SET pin_order = pin_order - 1 WHERE project_id = ? AND pin_order > ?", note.ProjectID, note.PinOrder)
+	if err != nil {
+		return err
+	}
+
+	_, err = db.GetEngine(ctx).Table("board_note").
+		Where("id = ?", note.ID).
+		Update(map[string]any{
+			"pin_order": 0,
+		})
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// MovePin moves a Pinned BoardNote to a new Position
+func (note *BoardNote) MovePin(ctx context.Context, newPosition int64) error {
+	// If the BoardNote is not pinned, we can't move them
+	if !note.IsPinned() {
+		return nil
+	}
+
+	if newPosition < 1 {
+		return fmt.Errorf("The Position can't be lower than 1")
+	}
+
+	dbctx, committer, err := db.TxContext(ctx)
+	if err != nil {
+		return err
+	}
+	defer committer.Close()
+
+	maxPin, err := note.GetMaxPinOrder(ctx)
+	if err != nil {
+		return err
+	}
+
+	// If the new Position bigger than the current Maximum, set it to the Maximum
+	if newPosition > maxPin+1 {
+		newPosition = maxPin + 1
+	}
+
+	// Lower the Position of all Pinned BoardNotes that came after the current Position
+	_, err = db.GetEngine(dbctx).Exec("UPDATE board_note SET pin_order = pin_order - 1 WHERE project_id = ? AND pin_order > ?", note.ProjectID, note.PinOrder)
+	if err != nil {
+		return err
+	}
+
+	// Higher the Position of all Pinned BoardNotes that comes after the new Position
+	_, err = db.GetEngine(dbctx).Exec("UPDATE board_note SET pin_order = pin_order + 1 WHERE project_id = ? AND pin_order >= ?", note.ProjectID, newPosition)
+	if err != nil {
+		return err
+	}
+
+	_, err = db.GetEngine(dbctx).Table("board_note").
+		Where("id = ?", note.ID).
+		Update(map[string]any{
+			"pin_order": newPosition,
+		})
+	if err != nil {
+		return err
+	}
+
+	return committer.Commit()
+}
+
+// GetPinnedBoardNotes returns the pinned BaordNotes for the given Project
+func GetPinnedBoardNotes(ctx context.Context, projectID int64) (BoardNoteList, error) {
+	notes := make(BoardNoteList, 0)
+
+	err := db.GetEngine(ctx).
+		Table("board_note").
+		Where("project_id = ?", projectID).
+		And("pin_order > 0").
+		OrderBy("pin_order").
+		Find(&notes)
+	if err != nil {
+		return nil, err
+	}
+
+	// @TODO: same code in `BoardNotes()` and should be used with `LoadAttributes()`
+	for _, note := range notes {
+		creator := new(user_model.User)
+		has, err := db.GetEngine(ctx).ID(note.CreatorID).Get(creator)
+		if err != nil {
+			return nil, err
+		}
+		if !has {
+			return nil, user_model.ErrUserNotExist{UID: note.CreatorID}
+		}
+		note.Creator = creator
+	}
+
+	return notes, nil
+}
+
+// GetLastEventTimestamp returns the last user visible event timestamp, either the creation or the update.
 func (note *BoardNote) GetLastEventTimestamp() timeutil.TimeStamp {
 	return max(note.CreatedUnix, note.UpdatedUnix)
 }
diff --git a/modules/setting/repository.go b/modules/setting/repository.go
index a6f0ed8833589..6a950e6b86e20 100644
--- a/modules/setting/repository.go
+++ b/modules/setting/repository.go
@@ -94,6 +94,10 @@ var (
 			MaxPinned   int
 		} `ini:"repository.issue"`
 
+		Project struct {
+			MaxPinned int64
+		} `ini:"repository.project"`
+
 		Release struct {
 			AllowedTypes     string
 			DefaultPagingNum int
@@ -237,6 +241,12 @@ var (
 			MaxPinned:   3,
 		},
 
+		Project: struct {
+			MaxPinned int64
+		}{
+			MaxPinned: 3,
+		},
+
 		Release: struct {
 			AllowedTypes     string
 			DefaultPagingNum int
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index f0d1089ddcfd0..e972e1a7d4575 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1362,6 +1362,7 @@ projects.note.delete = Delete Note
 projects.note.deletion_desc = Deleting removes the note from the project column. Continue?
 projects.note.created_by = created %[1]s by <a href="%[2]s">%[3]s</a>
 projects.note.updated_by = updated %[1]s by <a href="%[2]s">%[3]s</a>
+projects.note.max_pinned = You can't pin more notes
 
 issues.desc = Organize bug reports, tasks and milestones.
 issues.filter_assignees = Filter Assignee
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index a3dc4b42b00dc..2da39b04b4bbe 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -330,6 +330,7 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
+	// @TODO: maybe should be in BoardNote
 	for _, noteList := range notesMap {
 		for _, note := range noteList {
 			note.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
@@ -394,11 +395,34 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
+	pinnedBoardNotes, err := project_model.GetPinnedBoardNotes(ctx, project.ID)
+	if err != nil {
+		ctx.ServerError("GetPinnedBoardNotes", err)
+		return
+	}
+
+	// @TODO: maybe should be in BoardNote
+	for _, note := range pinnedBoardNotes {
+		note.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
+			Links: markup.Links{
+				Base: ctx.Repo.RepoLink,
+			},
+			Metas:   ctx.Repo.Repository.ComposeMetas(ctx),
+			GitRepo: ctx.Repo.GitRepo,
+			Ctx:     ctx,
+		}, note.Content)
+		if err != nil {
+			ctx.ServerError("RenderString", err)
+			return
+		}
+	}
+
 	ctx.Data["IsProjectsPage"] = true
 	ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
 	ctx.Data["Project"] = project
 	ctx.Data["IssuesMap"] = issuesMap
 	ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend
+	ctx.Data["PinnedNotes"] = pinnedBoardNotes
 	ctx.Data["NotesMap"] = notesMap
 
 	ctx.HTML(http.StatusOK, tplProjectsView)
@@ -918,3 +942,69 @@ func MoveBoardNote(ctx *context.Context) {
 
 	ctx.JSONOK()
 }
+
+// PinBoardNote pins the BoardNote
+func PinBoardNote(ctx *context.Context) {
+	note, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	if err != nil {
+		ctx.ServerError("GetBoardNoteByID", err)
+		return
+	}
+
+	err = note.Pin(ctx)
+	if err != nil {
+		ctx.ServerError("Pin", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
+
+// PinBoardNote unpins the BoardNote
+func UnPinBoardNote(ctx *context.Context) {
+	note, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	if err != nil {
+		ctx.ServerError("GetBoardNoteByID", err)
+		return
+	}
+
+	err = note.Unpin(ctx)
+	if err != nil {
+		ctx.ServerError("Unpin", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
+
+// PinBoardNote moves a pined the BoardNote
+func PinMoveBoardNote(ctx *context.Context) {
+	if ctx.Doer == nil {
+		ctx.JSON(http.StatusForbidden, "Only signed in users are allowed to perform this action.")
+		return
+	}
+
+	type movePinBoardNoteForm struct {
+		Position int64 `json:"position"`
+	}
+
+	form := &movePinBoardNoteForm{}
+	if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
+		ctx.ServerError("Decode movePinBoardNoteForm", err)
+		return
+	}
+
+	note, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	if err != nil {
+		ctx.ServerError("GetIssueByID", err)
+		return
+	}
+
+	err = note.MovePin(ctx, form.Position)
+	if err != nil {
+		ctx.ServerError("MovePin", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index b4709d55bd29f..711e38af71698 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1349,6 +1349,12 @@ func registerRoutes(m *web.Route) {
 							m.Group("/{noteID}", func() {
 								m.Put("", web.Bind(forms.BoardNoteForm{}), repo.EditBoardNote)
 								m.Delete("", repo.DeleteBoardNote)
+
+								m.Group("/pin", func() {
+									m.Post("", web.Bind(forms.BoardNoteForm{}), repo.PinBoardNote)
+									m.Delete("", repo.UnPinBoardNote)
+									m.Post("/move", repo.PinMoveBoardNote)
+								})
 							})
 						})
 					})
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index db6e7a477850b..314bb692136be 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -1,5 +1,22 @@
 {{$canWriteProject := and .CanWriteProjects (or (not .Repository) (not .Repository.IsArchived))}}
 
+{{if gt (len .PinnedNotes) 0}}
+<div id="pinned-notes">
+	{{range .PinnedNotes}}
+		<div class="note-card pinned-card {{if $canWriteProject}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-note="{{.ID}}">
+			{{template "repo/note" (dict
+				"BoardNote" .
+				"CanWriteProjects" $canWriteProject
+				"Link" $.Link
+				"RepoLink" $.RepoLink
+				"ProjectID" $.Project.ID
+				"CsrfTokenHtml" $.CsrfTokenHtml)
+			}}
+		</div>
+	{{end}}
+</div>
+{{end}}
+
 <div class="ui container">
 	<div class="gt-df gt-sb gt-ac gt-mb-4">
 		<h2 class="gt-mb-0">{{.Project.Title}}</h2>
@@ -66,7 +83,7 @@
 <div id="project-board">
 	<div class="board {{if .CanWriteProjects}}sortable{{end}}">
 		{{range .Columns}}
-			<div class="ui segment project-column" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
+			<div class="ui segment project-column {{if $canWriteProject}}gt-cursor-grab{{end}}" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
 				<div class="project-column-header">
 					<div class="ui large label project-column-title gt-py-2">
 						<div class="ui small circular grey label project-column-issue-note-count">
@@ -202,30 +219,28 @@
 				<div class="divider"></div>
 
 				<div class="cards-wrapper">
-					{{$columnID := .ID}}
-					{{if or $canWriteProject (index $.NotesMap $columnID)}}
+					{{if or $canWriteProject (index $.NotesMap .ID)}}
 						<div class="ui cards note-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
-							{{range (index $.NotesMap $columnID)}}
-								<div class="note-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-note="{{.ID}}">
+							{{range (index $.NotesMap .ID)}}
+								<div class="note-card" data-note="{{.ID}}">
 									{{template "repo/note" (dict
 										"BoardNote" .
 										"CanWriteProjects" $canWriteProject
 										"Link" $.Link
 										"RepoLink" $.RepoLink
-										"columnID" $columnID
 										"ProjectID" $.Project.ID
 										"CsrfTokenHtml" $.CsrfTokenHtml)
 									}}
 								</div>
 							{{end}}
 						</div>
-						{{if or $canWriteProject (index $.IssuesMap $columnID)}}
+						{{if or $canWriteProject (index $.IssuesMap .ID)}}
 							<div class="divider"></div>
 						{{end}}
 					{{end}}
 					<div class="ui cards issue-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="issue-board-{{.ID}}">
-						{{range (index $.IssuesMap $columnID)}}
-							<div class="issue-card gt-word-break {{if $canWriteProject}}gt-cursor-grab{{end}}" data-issue="{{.ID}}">
+						{{range (index $.IssuesMap .ID)}}
+							<div class="issue-card" data-issue="{{.ID}}">
 								{{template "repo/issue/card" (dict "Issue" . "Page" $)}}
 							</div>
 						{{end}}
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index bb41c7930e10c..585a77ce9260c 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -19,7 +19,7 @@
 					{{if $.CanWriteProjects}}
 						<button class="ui item show-modal button"
 							data-modal="#edit-project-column-note-modal-{{.ID}}"
-							data-modal-form.action="{{$.Link}}/{{$.columnID}}/note/{{.ID}}">
+							data-modal-form.action="{{$.Link}}/{{.BoardID}}/note/{{.ID}}">
 								{{svg "octicon-pencil"}}
 								{{ctx.Locale.Tr "repo.projects.note.edit"}}
 						</button>
@@ -27,9 +27,23 @@
 							{{svg "octicon-issue-opened"}}
 							{{ctx.Locale.Tr "repo.issues.new"}}
 						</a>
+						{{$notPinnedAndNotAllowedToPin := and (not .IsPinned) (not (.IsNewPinAllowed ctx))}}
+						<button class="ui item button {{if $notPinnedAndNotAllowedToPin}}disabled{{end}} pin-project-column-note"
+							{{if $notPinnedAndNotAllowedToPin}}style="pointer-events: unset !important"{{end}}
+							data-method="{{if .IsPinned}}delete{{else}}post{{end}}"
+							data-url="{{$.Link}}/{{.BoardID}}/note/{{.ID}}/pin"
+							{{if $notPinnedAndNotAllowedToPin}}data-tooltip-content="{{ctx.Locale.Tr "repo.projects.note.max_pinned"}}"{{end}}>
+								{{if .IsPinned}}
+									{{svg "octicon-pin-slash"}}
+									{{ctx.Locale.Tr "unpin"}}
+								{{else}}
+									{{svg "octicon-pin"}}
+									{{ctx.Locale.Tr "pin"}}
+								{{end}}
+						</button>
 						<button class="ui item show-modal button"
 							data-modal="#delete-project-column-note-modal-{{.ID}}"
-							data-modal-form.action="{{$.Link}}/{{$.columnID}}/note/{{.ID}}">
+							data-modal-form.action="{{$.Link}}/{{.BoardID}}/note/{{.ID}}">
 								{{svg "octicon-trash"}}
 								{{ctx.Locale.Tr "repo.projects.note.delete"}}
 						</button>
diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index 7974dc714d99d..eb9a27c3aa8a4 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -3,6 +3,13 @@
   overflow-y: auto;
 }
 
+#pinned-notes {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 8px;
+  margin-bottom: 8px;
+}
+
 .board {
   display: flex;
   flex-direction: row;
@@ -23,8 +30,6 @@
   overflow: visible;
   display: flex;
   flex-direction: column;
-
-  cursor: grab;
 }
 
 .project-column-header {
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index 564b734928869..04a67a625ec98 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -79,7 +79,36 @@ function moveNote({item, from, to, oldIndex}) {
   });
 }
 
+function movePinned({newIndex, item}) {
+  const newPosition = newIndex + 1;
+  const id = $(item).data('note');
+  const url = `${$(item).data('url')}/${id}/pin/move`;
+
+  $.ajax({
+    url,
+    data: JSON.stringify({position: newPosition}),
+    headers: {
+      'X-Csrf-Token': csrfToken,
+    },
+    contentType: 'application/json',
+    type: 'POST',
+  });
+}
+
 async function initRepoProjectSortable() {
+  const pinnedNotesCards = document.querySelector('#pinned-notes');
+  if (pinnedNotesCards) {
+    createSortable(pinnedNotesCards, {
+      group: 'pinned-shared',
+      animation: 150,
+      ghostClass: 'card-ghost',
+      onAdd: movePinned,
+      onUpdate: movePinned,
+      delayOnTouchOnly: true,
+      delay: 500
+    });
+  }
+
   const els = document.querySelectorAll('#project-board > .board.sortable');
   if (!els.length) return;
 
@@ -260,6 +289,29 @@ export function initRepoProject() {
     const form = columnTitle.closest('form');
     createNewColumn(url, form, {title: columnTitle.val(), color: projectColorInput.val()});
   });
+
+  $('.pin-project-column-note').each(function () {
+    const pinButton = $(this);
+    const pinUrl = pinButton.data('url');
+    const ajaxMethod = pinButton.data('method');
+
+    pinButton.on('click', (e) => {
+      e.preventDefault();
+
+      if (pinButton.hasClass('disabled')) return;
+
+      $.ajax({
+        url: pinUrl,
+        headers: {
+          'X-Csrf-Token': csrfToken,
+        },
+        contentType: 'application/json',
+        method: ajaxMethod,
+      }).done(() => {
+        window.location.reload();
+      });
+    });
+  });
 }
 
 function setLabelColor(label, color) {

From abb5152c68575ee5b0827837a5eb731a745b550d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Tue, 6 Feb 2024 20:33:06 +0100
Subject: [PATCH 25/46] chore: rename BoardNote to ProjectBoardNote

---
 models/project/board.go      |  16 ++--
 models/project/note.go       | 180 +++++++++++++++++------------------
 models/project/project.go    |   4 +-
 routers/web/repo/projects.go | 120 +++++++++++------------
 routers/web/web.go           |  14 +--
 services/forms/repo_form.go  |   4 +-
 templates/projects/view.tmpl |  10 +-
 7 files changed, 174 insertions(+), 174 deletions(-)

diff --git a/models/project/board.go b/models/project/board.go
index 2e80cfc6461c3..d759336cc44db 100644
--- a/models/project/board.go
+++ b/models/project/board.go
@@ -82,9 +82,9 @@ func (b *Board) NumIssues(ctx context.Context) int {
 	return int(c)
 }
 
-// NumNotes return counter of all notes assigned to the board
-func (b *Board) NumNotes(ctx context.Context) int {
-	c, err := db.GetEngine(ctx).Table("board_note").
+// NumProjectBoardNotes return counter of all notes assigned to the board
+func (b *Board) NumProjectBoardNotes(ctx context.Context) int {
+	c, err := db.GetEngine(ctx).Table("project_board_note").
 		Where("project_id=?", b.ProjectID).
 		And("board_id=?", b.ID).
 		GroupBy("id").
@@ -96,11 +96,11 @@ func (b *Board) NumNotes(ctx context.Context) int {
 	return int(c)
 }
 
-// NumIssuesAndNotes return counter of all issues and notes assigned to the board
-func (b *Board) NumIssuesAndNotes(ctx context.Context) int {
+// NumIssuesAndProjectBoardNotes return counter of all issues and notes assigned to the board
+func (b *Board) NumIssuesAndProjectBoardNotes(ctx context.Context) int {
 	numIssues := b.NumIssues(ctx)
-	numNotes := b.NumNotes(ctx)
-	return numIssues + numNotes
+	numProjectBoardNotes := b.NumProjectBoardNotes(ctx)
+	return numIssues + numProjectBoardNotes
 }
 
 func init() {
@@ -201,7 +201,7 @@ func deleteBoardByID(ctx context.Context, boardID int64) error {
 		return err
 	}
 
-	if err = board.removeBoardNotes(ctx); err != nil {
+	if err = board.removeProjectBoardNotes(ctx); err != nil {
 		return err
 	}
 
diff --git a/models/project/note.go b/models/project/note.go
index 4825d7bc04c4a..e66aa45372860 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -17,8 +17,8 @@ import (
 	"xorm.io/builder"
 )
 
-// BoardNote is used to represent a note on a boards
-type BoardNote struct {
+// ProjectBoardNote is used to represent a note on a boards
+type ProjectBoardNote struct {
 	ID              int64  `xorm:"pk autoincr"`
 	Title           string `xorm:"TEXT NOT NULL"`
 	Content         string `xorm:"LONGTEXT"`
@@ -35,60 +35,60 @@ type BoardNote struct {
 	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
 }
 
-type BoardNoteList = []*BoardNote
+type ProjectBoardNoteList []*ProjectBoardNote
 
-// NotesOptions represents options of an note.
-type NotesOptions struct {
+// ProjectBoardNotesOptions represents options of an note.
+type ProjectBoardNotesOptions struct {
 	db.Paginator
 	ProjectID int64
 	BoardID   int64
 }
 
 func init() {
-	db.RegisterModel(new(BoardNote))
+	db.RegisterModel(new(ProjectBoardNote))
 }
 
-// GetBoardNoteByID load notes assigned to the boards
-func GetBoardNoteByID(ctx context.Context, noteID int64) (*BoardNote, error) {
-	note := new(BoardNote)
+// GetProjectBoardNoteByID load notes assigned to the boards
+func GetProjectBoardNoteByID(ctx context.Context, projectBoardNoteID int64) (*ProjectBoardNote, error) {
+	projectBoardNote := new(ProjectBoardNote)
 
-	has, err := db.GetEngine(ctx).ID(noteID).Get(note)
+	has, err := db.GetEngine(ctx).ID(projectBoardNoteID).Get(projectBoardNote)
 	if err != nil {
 		return nil, err
 	} else if !has {
-		return nil, ErrProjectBoardNoteNotExist{BoardNoteID: noteID}
+		return nil, ErrProjectBoardNoteNotExist{BoardNoteID: projectBoardNoteID}
 	}
 
-	return note, nil
+	return projectBoardNote, nil
 }
 
-// GetBoardNoteByIds return notes with the given IDs.
-func GetBoardNoteByIds(ctx context.Context, noteIDs []int64) (BoardNoteList, error) {
-	notes := make(BoardNoteList, 0, len(noteIDs))
+// GetProjectBoardNotesByIds return notes with the given IDs.
+func GetProjectBoardNotesByIds(ctx context.Context, projectBoardNoteIDs []int64) (ProjectBoardNoteList, error) {
+	projectBoardNoteList := make(ProjectBoardNoteList, 0, len(projectBoardNoteIDs))
 
-	if err := db.GetEngine(ctx).In("id", noteIDs).Find(&notes); err != nil {
+	if err := db.GetEngine(ctx).In("id", projectBoardNoteIDs).Find(&projectBoardNoteList); err != nil {
 		return nil, err
 	}
 
-	return notes, nil
+	return projectBoardNoteList, nil
 }
 
-// LoadBoardNotesFromBoardList load notes assigned to the boards
-func (p *Project) LoadBoardNotesFromBoardList(ctx context.Context, bs BoardList) (map[int64]BoardNoteList, error) {
-	notesMap := make(map[int64]BoardNoteList, len(bs))
-	for i := range bs {
-		il, err := LoadBoardNotesFromBoard(ctx, bs[i])
+// LoadProjectBoardNotesFromBoardList load notes assigned to the boards
+func (p *Project) LoadProjectBoardNotesFromBoardList(ctx context.Context, boardList BoardList) (map[int64]ProjectBoardNoteList, error) {
+	projectBoardNoteListMap := make(map[int64]ProjectBoardNoteList, len(boardList))
+	for i := range boardList {
+		il, err := LoadProjectBoardNotesFromBoard(ctx, boardList[i])
 		if err != nil {
 			return nil, err
 		}
-		notesMap[bs[i].ID] = il
+		projectBoardNoteListMap[boardList[i].ID] = il
 	}
-	return notesMap, nil
+	return projectBoardNoteListMap, nil
 }
 
-// LoadBoardNotesFromBoard load notes assigned to this board
-func LoadBoardNotesFromBoard(ctx context.Context, board *Board) (BoardNoteList, error) {
-	notes, err := BoardNotes(ctx, &NotesOptions{
+// LoadProjectBoardNotesFromBoard load notes assigned to this board
+func LoadProjectBoardNotesFromBoard(ctx context.Context, board *Board) (ProjectBoardNoteList, error) {
+	projectBoardNoteList, err := ProjectBoardNotes(ctx, &ProjectBoardNotesOptions{
 		ProjectID: board.ProjectID,
 		BoardID:   board.ID,
 	})
@@ -96,22 +96,22 @@ func LoadBoardNotesFromBoard(ctx context.Context, board *Board) (BoardNoteList,
 		return nil, err
 	}
 
-	return notes, nil
+	return projectBoardNoteList, nil
 }
 
-// BoardNotes returns a list of notes by given conditions.
-func BoardNotes(ctx context.Context, opts *NotesOptions) (BoardNoteList, error) {
+// ProjectBoardNotes returns a list of notes by given conditions.
+func ProjectBoardNotes(ctx context.Context, opts *ProjectBoardNotesOptions) (ProjectBoardNoteList, error) {
 	sess := db.GetEngine(ctx)
 
 	sess.Where(builder.Eq{"board_id": opts.BoardID}).And(builder.Eq{"project_id": opts.ProjectID})
 
-	notes := BoardNoteList{}
-	if err := sess.Asc("sorting").Desc("updated_unix").Desc("id").Find(&notes); err != nil {
-		return nil, fmt.Errorf("unable to query Notes: %w", err)
+	projectBoardNoteList := ProjectBoardNoteList{}
+	if err := sess.Asc("sorting").Desc("updated_unix").Desc("id").Find(&projectBoardNoteList); err != nil {
+		return nil, fmt.Errorf("unable to query project-board-notes: %w", err)
 	}
 
 	// @TODO: same code in `GetPinnedBoardNotes()` and should be used with `LoadAttributes()`
-	for _, note := range notes {
+	for _, note := range projectBoardNoteList {
 		creator := new(user_model.User)
 		has, err := db.GetEngine(ctx).ID(note.CreatorID).Get(creator)
 		if err != nil {
@@ -123,12 +123,12 @@ func BoardNotes(ctx context.Context, opts *NotesOptions) (BoardNoteList, error)
 		note.Creator = creator
 	}
 
-	return notes, nil
+	return projectBoardNoteList, nil
 }
 
-// NewBoardNote adds a new note to a given board
-func NewBoardNote(ctx context.Context, note *BoardNote) error {
-	_, err := db.GetEngine(ctx).Insert(note)
+// NewProjectBoardNote adds a new note to a given board
+func NewProjectBoardNote(ctx context.Context, projectBoardNote *ProjectBoardNote) error {
+	_, err := db.GetEngine(ctx).Insert(projectBoardNote)
 	return err
 }
 
@@ -138,17 +138,17 @@ func (notes BoardNoteList) LoadAttributes() error {
 	return nil
 } */
 
-var ErrBoardNoteMaxPinReached = util.NewInvalidArgumentErrorf("the max number of pinned board-notes has been readched")
+var ErrBoardNoteMaxPinReached = util.NewInvalidArgumentErrorf("the max number of pinned project-board-notes has been readched")
 
 // IsPinned returns if a BoardNote is pinned
-func (note *BoardNote) IsPinned() bool {
-	return note.PinOrder != 0
+func (projectBoardNote *ProjectBoardNote) IsPinned() bool {
+	return projectBoardNote.PinOrder != 0
 }
 
 // IsPinned returns if a BoardNote is pinned
-func (note *BoardNote) GetMaxPinOrder(ctx context.Context) (int64, error) {
+func (projectBoardNote *ProjectBoardNote) GetMaxPinOrder(ctx context.Context) (int64, error) {
 	var maxPin int64
-	_, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM board_note WHERE project_id = ?", note.ProjectID).Get(&maxPin)
+	_, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM project_board_note WHERE project_id = ?", projectBoardNote.ProjectID).Get(&maxPin)
 	if err != nil {
 		return -1, err
 	}
@@ -156,8 +156,8 @@ func (note *BoardNote) GetMaxPinOrder(ctx context.Context) (int64, error) {
 }
 
 // IsPinned returns if a BoardNote is pinned
-func (note *BoardNote) IsNewPinAllowed(ctx context.Context) bool {
-	maxPin, err := note.GetMaxPinOrder(ctx)
+func (projectBoardNote *ProjectBoardNote) IsNewPinAllowed(ctx context.Context) bool {
+	maxPin, err := projectBoardNote.GetMaxPinOrder(ctx)
 	if err != nil {
 		return false
 	}
@@ -167,13 +167,13 @@ func (note *BoardNote) IsNewPinAllowed(ctx context.Context) bool {
 }
 
 // Pin pins a BoardNote
-func (note *BoardNote) Pin(ctx context.Context) error {
+func (projectBoardNote *ProjectBoardNote) Pin(ctx context.Context) error {
 	// If the BoardNote is already pinned, we don't need to pin it twice
-	if note.IsPinned() {
+	if projectBoardNote.IsPinned() {
 		return nil
 	}
 
-	maxPin, err := note.GetMaxPinOrder(ctx)
+	maxPin, err := projectBoardNote.GetMaxPinOrder(ctx)
 	if err != nil {
 		return err
 	}
@@ -183,8 +183,8 @@ func (note *BoardNote) Pin(ctx context.Context) error {
 		return ErrBoardNoteMaxPinReached
 	}
 
-	_, err = db.GetEngine(ctx).Table("board_note").
-		Where("id = ?", note.ID).
+	_, err = db.GetEngine(ctx).Table("project_board_note").
+		Where("id = ?", projectBoardNote.ID).
 		Update(map[string]any{
 			"pin_order": maxPin + 1,
 		})
@@ -196,20 +196,20 @@ func (note *BoardNote) Pin(ctx context.Context) error {
 }
 
 // Unpin unpins a BoardNote
-func (note *BoardNote) Unpin(ctx context.Context) error {
+func (projectBoardNote *ProjectBoardNote) Unpin(ctx context.Context) error {
 	// If the BoardNote is not pinned, we don't need to unpin it
-	if !note.IsPinned() {
+	if !projectBoardNote.IsPinned() {
 		return nil
 	}
 
 	// This sets the Pin for all BoardNotes that come after the unpined BoardNote to the correct value
-	_, err := db.GetEngine(ctx).Exec("UPDATE board_note SET pin_order = pin_order - 1 WHERE project_id = ? AND pin_order > ?", note.ProjectID, note.PinOrder)
+	_, err := db.GetEngine(ctx).Exec("UPDATE project_board_note SET pin_order = pin_order - 1 WHERE project_id = ? AND pin_order > ?", projectBoardNote.ProjectID, projectBoardNote.PinOrder)
 	if err != nil {
 		return err
 	}
 
-	_, err = db.GetEngine(ctx).Table("board_note").
-		Where("id = ?", note.ID).
+	_, err = db.GetEngine(ctx).Table("project_board_note").
+		Where("id = ?", projectBoardNote.ID).
 		Update(map[string]any{
 			"pin_order": 0,
 		})
@@ -221,9 +221,9 @@ func (note *BoardNote) Unpin(ctx context.Context) error {
 }
 
 // MovePin moves a Pinned BoardNote to a new Position
-func (note *BoardNote) MovePin(ctx context.Context, newPosition int64) error {
+func (projectBoardNote *ProjectBoardNote) MovePin(ctx context.Context, newPosition int64) error {
 	// If the BoardNote is not pinned, we can't move them
-	if !note.IsPinned() {
+	if !projectBoardNote.IsPinned() {
 		return nil
 	}
 
@@ -237,7 +237,7 @@ func (note *BoardNote) MovePin(ctx context.Context, newPosition int64) error {
 	}
 	defer committer.Close()
 
-	maxPin, err := note.GetMaxPinOrder(ctx)
+	maxPin, err := projectBoardNote.GetMaxPinOrder(ctx)
 	if err != nil {
 		return err
 	}
@@ -248,19 +248,19 @@ func (note *BoardNote) MovePin(ctx context.Context, newPosition int64) error {
 	}
 
 	// Lower the Position of all Pinned BoardNotes that came after the current Position
-	_, err = db.GetEngine(dbctx).Exec("UPDATE board_note SET pin_order = pin_order - 1 WHERE project_id = ? AND pin_order > ?", note.ProjectID, note.PinOrder)
+	_, err = db.GetEngine(dbctx).Exec("UPDATE project_board_note SET pin_order = pin_order - 1 WHERE project_id = ? AND pin_order > ?", projectBoardNote.ProjectID, projectBoardNote.PinOrder)
 	if err != nil {
 		return err
 	}
 
 	// Higher the Position of all Pinned BoardNotes that comes after the new Position
-	_, err = db.GetEngine(dbctx).Exec("UPDATE board_note SET pin_order = pin_order + 1 WHERE project_id = ? AND pin_order >= ?", note.ProjectID, newPosition)
+	_, err = db.GetEngine(dbctx).Exec("UPDATE project_board_note SET pin_order = pin_order + 1 WHERE project_id = ? AND pin_order >= ?", projectBoardNote.ProjectID, newPosition)
 	if err != nil {
 		return err
 	}
 
-	_, err = db.GetEngine(dbctx).Table("board_note").
-		Where("id = ?", note.ID).
+	_, err = db.GetEngine(dbctx).Table("project_board_note").
+		Where("id = ?", projectBoardNote.ID).
 		Update(map[string]any{
 			"pin_order": newPosition,
 		})
@@ -271,12 +271,12 @@ func (note *BoardNote) MovePin(ctx context.Context, newPosition int64) error {
 	return committer.Commit()
 }
 
-// GetPinnedBoardNotes returns the pinned BaordNotes for the given Project
-func GetPinnedBoardNotes(ctx context.Context, projectID int64) (BoardNoteList, error) {
-	notes := make(BoardNoteList, 0)
+// GetPinnedProjectBoardNotes returns the pinned BaordNotes for the given Project
+func GetPinnedProjectBoardNotes(ctx context.Context, projectID int64) (ProjectBoardNoteList, error) {
+	notes := make(ProjectBoardNoteList, 0)
 
 	err := db.GetEngine(ctx).
-		Table("board_note").
+		Table("project_board_note").
 		Where("project_id = ?", projectID).
 		And("pin_order > 0").
 		OrderBy("pin_order").
@@ -302,46 +302,46 @@ func GetPinnedBoardNotes(ctx context.Context, projectID int64) (BoardNoteList, e
 }
 
 // GetLastEventTimestamp returns the last user visible event timestamp, either the creation or the update.
-func (note *BoardNote) GetLastEventTimestamp() timeutil.TimeStamp {
-	return max(note.CreatedUnix, note.UpdatedUnix)
+func (projectBoardNote *ProjectBoardNote) GetLastEventTimestamp() timeutil.TimeStamp {
+	return max(projectBoardNote.CreatedUnix, projectBoardNote.UpdatedUnix)
 }
 
 // GetLastEventLabel returns the localization label for the current note.
-func (note *BoardNote) GetLastEventLabel() string {
-	if note.UpdatedUnix > note.CreatedUnix {
+func (projectBoardNote *ProjectBoardNote) GetLastEventLabel() string {
+	if projectBoardNote.UpdatedUnix > projectBoardNote.CreatedUnix {
 		return "repo.projects.note.updated_by"
 	}
 	return "repo.projects.note.created_by"
 }
 
-// GetTasks returns the amount of tasks in the board-notes content
-func (note *BoardNote) GetTasks() int {
-	return len(markdown.MarkdownTasksRegex.FindAllStringIndex(note.Content, -1))
+// GetTasks returns the amount of tasks in the project-board-notes content
+func (projectBoardNote *ProjectBoardNote) GetTasks() int {
+	return len(markdown.MarkdownTasksRegex.FindAllStringIndex(projectBoardNote.Content, -1))
 }
 
-// GetTasksDone returns the amount of completed tasks in the board-notes content
-func (note *BoardNote) GetTasksDone() int {
-	return len(markdown.MarkdownTasksDoneRegex.FindAllStringIndex(note.Content, -1))
+// GetTasksDone returns the amount of completed tasks in the project-board-notes content
+func (projectBoardNote *ProjectBoardNote) GetTasksDone() int {
+	return len(markdown.MarkdownTasksDoneRegex.FindAllStringIndex(projectBoardNote.Content, -1))
 }
 
-// UpdateBoardNote changes a BoardNote
-func UpdateBoardNote(ctx context.Context, note *BoardNote) error {
+// UpdateProjectBoardNote changes a BoardNote
+func UpdateProjectBoardNote(ctx context.Context, projectBoardNote *ProjectBoardNote) error {
 	var fieldToUpdate []string
 
 	fieldToUpdate = append(fieldToUpdate, "title")
 	fieldToUpdate = append(fieldToUpdate, "content")
 
-	_, err := db.GetEngine(ctx).ID(note.ID).Cols(fieldToUpdate...).Update(note)
+	_, err := db.GetEngine(ctx).ID(projectBoardNote.ID).Cols(fieldToUpdate...).Update(projectBoardNote)
 	return err
 }
 
-// MoveBoardNoteOnProjectBoard moves or keeps notes in a column and sorts them inside that column
-func MoveBoardNoteOnProjectBoard(ctx context.Context, board *Board, sortedNoteIDs map[int64]int64) error {
+// MoveProjectBoardNoteOnProjectBoard moves or keeps notes in a column and sorts them inside that column
+func MoveProjectBoardNoteOnProjectBoard(ctx context.Context, board *Board, sortedProjectBoardNoteIDs map[int64]int64) error {
 	return db.WithTx(ctx, func(ctx context.Context) error {
 		sess := db.GetEngine(ctx)
 
-		for sorting, issueID := range sortedNoteIDs {
-			_, err := sess.Exec("UPDATE `board_note` SET board_id=?, sorting=? WHERE id=?", board.ID, sorting, issueID)
+		for sorting, issueID := range sortedProjectBoardNoteIDs {
+			_, err := sess.Exec("UPDATE `project_board_note` SET board_id=?, sorting=? WHERE id=?", board.ID, sorting, issueID)
 			if err != nil {
 				return err
 			}
@@ -350,21 +350,21 @@ func MoveBoardNoteOnProjectBoard(ctx context.Context, board *Board, sortedNoteID
 	})
 }
 
-func deleteBoardNoteByProjectID(ctx context.Context, projectID int64) error {
-	_, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&BoardNote{})
+func deleteProjectBoardNoteByProjectID(ctx context.Context, projectID int64) error {
+	_, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&ProjectBoardNote{})
 	return err
 }
 
-// DeleteBoardNote removes the BoardNote from the project board.
-func DeleteBoardNote(ctx context.Context, boardNote *BoardNote) error {
+// DeleteProjectBoardNote removes the BoardNote from the project board.
+func DeleteProjectBoardNote(ctx context.Context, boardNote *ProjectBoardNote) error {
 	if _, err := db.GetEngine(ctx).Delete(boardNote); err != nil {
 		return err
 	}
 	return nil
 }
 
-// removeBoardNotes sets the boardID to 0 for the board
-func (b *Board) removeBoardNotes(ctx context.Context) error {
-	_, err := db.GetEngine(ctx).Exec("UPDATE `board_note` SET board_id = 0 WHERE board_id = ?", b.ID)
+// removeProjectBoardNotes sets the boardID to 0 for the board
+func (b *Board) removeProjectBoardNotes(ctx context.Context) error {
+	_, err := db.GetEngine(ctx).Exec("UPDATE `project_board_note` SET board_id = 0 WHERE board_id = ?", b.ID)
 	return err
 }
diff --git a/models/project/project.go b/models/project/project.go
index c42e8b47b2bef..a9fe317537f09 100644
--- a/models/project/project.go
+++ b/models/project/project.go
@@ -97,7 +97,7 @@ func IsErrProjectBoardNoteNotExist(err error) bool {
 }
 
 func (err ErrProjectBoardNoteNotExist) Error() string {
-	return fmt.Sprintf("project board-note does not exist [id: %d]", err.BoardNoteID)
+	return fmt.Sprintf("project-board-note does not exist [id: %d]", err.BoardNoteID)
 }
 
 func (err ErrProjectBoardNoteNotExist) Unwrap() error {
@@ -430,7 +430,7 @@ func DeleteProjectByID(ctx context.Context, id int64) error {
 			return err
 		}
 
-		if err := deleteBoardNoteByProjectID(ctx, id); err != nil {
+		if err := deleteProjectBoardNoteByProjectID(ctx, id); err != nil {
 			return err
 		}
 
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 2da39b04b4bbe..47e4e7bcc08ae 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -324,7 +324,7 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	notesMap, err := project.LoadBoardNotesFromBoardList(ctx, boards)
+	notesMap, err := project.LoadProjectBoardNotesFromBoardList(ctx, boards)
 	if err != nil {
 		ctx.ServerError("LoadNotesOfBoards", err)
 		return
@@ -395,7 +395,7 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	pinnedBoardNotes, err := project_model.GetPinnedBoardNotes(ctx, project.ID)
+	pinnedBoardNotes, err := project_model.GetPinnedProjectBoardNotes(ctx, project.ID)
 	if err != nil {
 		ctx.ServerError("GetPinnedBoardNotes", err)
 		return
@@ -422,8 +422,8 @@ func ViewProject(ctx *context.Context) {
 	ctx.Data["Project"] = project
 	ctx.Data["IssuesMap"] = issuesMap
 	ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend
-	ctx.Data["PinnedNotes"] = pinnedBoardNotes
-	ctx.Data["NotesMap"] = notesMap
+	ctx.Data["PinnedProjectBoardNotes"] = pinnedBoardNotes
+	ctx.Data["ProjectBoardNotesMap"] = notesMap
 
 	ctx.HTML(http.StatusOK, tplProjectsView)
 }
@@ -747,7 +747,7 @@ func MoveIssues(ctx *context.Context) {
 	ctx.JSONOK()
 }
 
-func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.BoardNote) {
+func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.ProjectBoardNote) {
 	if ctx.Doer == nil {
 		ctx.JSON(http.StatusForbidden, map[string]string{
 			"message": "Only signed in users are allowed to perform this action.",
@@ -772,33 +772,33 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 		return nil, nil
 	}
 
-	note, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
 		if project_model.IsErrProjectBoardNoteNotExist(err) {
 			ctx.NotFound("ProjectBoardNoteNotFound", err)
 		} else {
-			ctx.ServerError("GetBoardNoteById", err)
+			ctx.ServerError("GetProjectBoardNoteById", err)
 		}
 		return nil, nil
 	}
-	if note.ProjectID != project.ID {
+	if projectBoardNote.ProjectID != project.ID {
 		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
-			"message": fmt.Sprintf("ProjectBoardNote[%d] is not in Project[%d] as expected", note.ID, project.ID),
+			"message": fmt.Sprintf("ProjectBoardNote[%d] is not in Project[%d] as expected", projectBoardNote.ID, project.ID),
 		})
 		return nil, nil
 	}
 
 	if project.RepoID != ctx.Repo.Repository.ID {
 		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
-			"message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", note.ID, ctx.Repo.Repository.ID),
+			"message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", projectBoardNote.ID, ctx.Repo.Repository.ID),
 		})
 		return nil, nil
 	}
-	return project, note
+	return project, projectBoardNote
 }
 
-func AddNoteToBoard(ctx *context.Context) {
-	form := web.GetForm(ctx).(*forms.BoardNoteForm)
+func AddProjectBoardNoteToBoard(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.ProjectBoardNoteForm)
 	if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) {
 		ctx.JSON(http.StatusForbidden, map[string]string{
 			"message": "Only authorized users are allowed to perform this action.",
@@ -806,7 +806,7 @@ func AddNoteToBoard(ctx *context.Context) {
 		return
 	}
 
-	if err := project_model.NewBoardNote(ctx, &project_model.BoardNote{
+	if err := project_model.NewProjectBoardNote(ctx, &project_model.ProjectBoardNote{
 		Title:   form.Title,
 		Content: form.Content,
 
@@ -821,17 +821,17 @@ func AddNoteToBoard(ctx *context.Context) {
 	ctx.JSONOK()
 }
 
-func EditBoardNote(ctx *context.Context) {
-	form := web.GetForm(ctx).(*forms.BoardNoteForm)
-	_, boardNote := checkProjectBoardNoteChangePermissions(ctx)
+func EditProjectBoardNote(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.ProjectBoardNoteForm)
+	_, projectBoardNote := checkProjectBoardNoteChangePermissions(ctx)
 	if ctx.Written() {
 		return
 	}
 
-	boardNote.Title = form.Title
-	boardNote.Content = form.Content
+	projectBoardNote.Title = form.Title
+	projectBoardNote.Content = form.Content
 
-	if err := project_model.UpdateBoardNote(ctx, boardNote); err != nil {
+	if err := project_model.UpdateProjectBoardNote(ctx, projectBoardNote); err != nil {
 		ctx.ServerError("UpdateProjectBoardNote", err)
 		return
 	}
@@ -839,21 +839,21 @@ func EditBoardNote(ctx *context.Context) {
 	ctx.JSONOK()
 }
 
-func DeleteBoardNote(ctx *context.Context) {
-	_, boardNote := checkProjectBoardNoteChangePermissions(ctx)
+func DeleteProjectBoardNote(ctx *context.Context) {
+	_, projectBoardNote := checkProjectBoardNoteChangePermissions(ctx)
 	if ctx.Written() {
 		return
 	}
 
-	if err := project_model.DeleteBoardNote(ctx, boardNote); err != nil {
-		ctx.ServerError("DeleteBoardNote", err)
+	if err := project_model.DeleteProjectBoardNote(ctx, projectBoardNote); err != nil {
+		ctx.ServerError("DeleteProjectBoardNote", err)
 		return
 	}
 
 	ctx.JSONOK()
 }
 
-func MoveBoardNote(ctx *context.Context) {
+func MoveProjectBoardNote(ctx *context.Context) {
 	if ctx.Doer == nil {
 		ctx.JSON(http.StatusForbidden, map[string]string{
 			"message": "Only signed in users are allowed to perform this action.",
@@ -902,58 +902,58 @@ func MoveBoardNote(ctx *context.Context) {
 		}
 	}
 
-	type MovedBoardNotesForm struct {
-		BoardNotes []struct {
-			BoardNoteID int64 `json:"boardNoteID"`
-			Sorting     int64 `json:"sorting"`
-		} `json:"boardNotes"`
+	type MovedProjectBoardNotesForm struct {
+		ProjectBoardNotes []struct {
+			ProjectBoardNoteID int64 `json:"projectBoardNoteID"`
+			Sorting            int64 `json:"sorting"`
+		} `json:"projectBoardNotes"`
 	}
 
-	form := &MovedBoardNotesForm{}
+	form := &MovedProjectBoardNotesForm{}
 	if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
-		ctx.ServerError("DecodeMovedBoardNotesForm", err)
+		ctx.ServerError("DecodeMovedProjectBoardNotesForm", err)
 	}
 
-	boardNoteIDs := make([]int64, 0, len(form.BoardNotes))
-	sortedBoardNoteIDs := make(map[int64]int64)
-	for _, boardNote := range form.BoardNotes {
-		boardNoteIDs = append(boardNoteIDs, boardNote.BoardNoteID)
-		sortedBoardNoteIDs[boardNote.Sorting] = boardNote.BoardNoteID
+	projectBoardNoteIDs := make([]int64, 0, len(form.ProjectBoardNotes))
+	sortedProjectBoardNoteIDs := make(map[int64]int64)
+	for _, boardNote := range form.ProjectBoardNotes {
+		projectBoardNoteIDs = append(projectBoardNoteIDs, boardNote.ProjectBoardNoteID)
+		sortedProjectBoardNoteIDs[boardNote.Sorting] = boardNote.ProjectBoardNoteID
 	}
-	movedBoardNotes, err := project_model.GetBoardNoteByIds(ctx, boardNoteIDs)
+	movedProjectBoardNotes, err := project_model.GetProjectBoardNotesByIds(ctx, projectBoardNoteIDs)
 	if err != nil {
 		if project_model.IsErrProjectBoardNoteNotExist(err) {
-			ctx.NotFound("BoardNoteNotExisting", nil)
+			ctx.NotFound("ProjectBoardNoteNotExisting", nil)
 		} else {
-			ctx.ServerError("GetBoardNoteByIds", err)
+			ctx.ServerError("GetProjectBoardNoteByIds", err)
 		}
 		return
 	}
 
-	if len(movedBoardNotes) != len(form.BoardNotes) {
-		ctx.ServerError("some board-notes do not exist", errors.New("some board-notes do not exist"))
+	if len(movedProjectBoardNotes) != len(form.ProjectBoardNotes) {
+		ctx.ServerError("some project-board-notes do not exist", errors.New("some project-board-notes do not exist"))
 		return
 	}
 
-	if err = project_model.MoveBoardNoteOnProjectBoard(ctx, board, sortedBoardNoteIDs); err != nil {
-		ctx.ServerError("MoveBoardNoteOnProjectBoard", err)
+	if err = project_model.MoveProjectBoardNoteOnProjectBoard(ctx, board, sortedProjectBoardNoteIDs); err != nil {
+		ctx.ServerError("MoveProjectBoardNoteOnProjectBoard", err)
 		return
 	}
 
 	ctx.JSONOK()
 }
 
-// PinBoardNote pins the BoardNote
-func PinBoardNote(ctx *context.Context) {
-	note, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+// PinProjectBoardNote pins the BoardNote
+func PinProjectBoardNote(ctx *context.Context) {
+	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
-		ctx.ServerError("GetBoardNoteByID", err)
+		ctx.ServerError("GetProjectBoardNoteByID", err)
 		return
 	}
 
-	err = note.Pin(ctx)
+	err = projectBoardNote.Pin(ctx)
 	if err != nil {
-		ctx.ServerError("Pin", err)
+		ctx.ServerError("PinProjectBoardNote", err)
 		return
 	}
 
@@ -961,8 +961,8 @@ func PinBoardNote(ctx *context.Context) {
 }
 
 // PinBoardNote unpins the BoardNote
-func UnPinBoardNote(ctx *context.Context) {
-	note, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+func UnPinProjectBoardNote(ctx *context.Context) {
+	note, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
 		ctx.ServerError("GetBoardNoteByID", err)
 		return
@@ -970,7 +970,7 @@ func UnPinBoardNote(ctx *context.Context) {
 
 	err = note.Unpin(ctx)
 	if err != nil {
-		ctx.ServerError("Unpin", err)
+		ctx.ServerError("UnpinProjectBoardNote", err)
 		return
 	}
 
@@ -978,31 +978,31 @@ func UnPinBoardNote(ctx *context.Context) {
 }
 
 // PinBoardNote moves a pined the BoardNote
-func PinMoveBoardNote(ctx *context.Context) {
+func PinMoveProjectBoardNote(ctx *context.Context) {
 	if ctx.Doer == nil {
 		ctx.JSON(http.StatusForbidden, "Only signed in users are allowed to perform this action.")
 		return
 	}
 
-	type movePinBoardNoteForm struct {
+	type MovePinProjectBoardNoteForm struct {
 		Position int64 `json:"position"`
 	}
 
-	form := &movePinBoardNoteForm{}
+	form := &MovePinProjectBoardNoteForm{}
 	if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
-		ctx.ServerError("Decode movePinBoardNoteForm", err)
+		ctx.ServerError("Decode MovePinProjectBoardNoteForm", err)
 		return
 	}
 
-	note, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	note, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
-		ctx.ServerError("GetIssueByID", err)
+		ctx.ServerError("GetProjectBoardNoteByID", err)
 		return
 	}
 
 	err = note.MovePin(ctx, form.Position)
 	if err != nil {
-		ctx.ServerError("MovePin", err)
+		ctx.ServerError("MovePinProjectBoardNote", err)
 		return
 	}
 
diff --git a/routers/web/web.go b/routers/web/web.go
index 711e38af71698..f16bab10069cf 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1343,17 +1343,17 @@ func registerRoutes(m *web.Route) {
 						m.Post("/move", repo.MoveIssues)
 
 						m.Group("/note", func() {
-							m.Post("", web.Bind(forms.BoardNoteForm{}), repo.AddNoteToBoard)
-							m.Post("/move", repo.MoveBoardNote)
+							m.Post("", web.Bind(forms.ProjectBoardNoteForm{}), repo.AddProjectBoardNoteToBoard)
+							m.Post("/move", repo.MoveProjectBoardNote)
 
 							m.Group("/{noteID}", func() {
-								m.Put("", web.Bind(forms.BoardNoteForm{}), repo.EditBoardNote)
-								m.Delete("", repo.DeleteBoardNote)
+								m.Put("", web.Bind(forms.ProjectBoardNoteForm{}), repo.EditProjectBoardNote)
+								m.Delete("", repo.DeleteProjectBoardNote)
 
 								m.Group("/pin", func() {
-									m.Post("", web.Bind(forms.BoardNoteForm{}), repo.PinBoardNote)
-									m.Delete("", repo.UnPinBoardNote)
-									m.Post("/move", repo.PinMoveBoardNote)
+									m.Post("", web.Bind(forms.ProjectBoardNoteForm{}), repo.PinProjectBoardNote)
+									m.Delete("", repo.UnPinProjectBoardNote)
+									m.Post("/move", repo.PinMoveProjectBoardNote)
 								})
 							})
 						})
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index c412ada440379..2f286f1a2116c 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -533,8 +533,8 @@ type EditProjectBoardForm struct {
 	Color   string `binding:"MaxSize(7)"`
 }
 
-// BoardNoteForm is a form for editing/creating a note to a board
-type BoardNoteForm struct {
+// ProjectBoardNoteForm is a form for editing/creating a note to a board
+type ProjectBoardNoteForm struct {
 	Title   string `binding:"Required;MaxSize(255)"`
 	Content string
 }
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 314bb692136be..1da3dbae7ea43 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -1,8 +1,8 @@
 {{$canWriteProject := and .CanWriteProjects (or (not .Repository) (not .Repository.IsArchived))}}
 
-{{if gt (len .PinnedNotes) 0}}
+{{if gt (len .PinnedProjectBoardNotes) 0}}
 <div id="pinned-notes">
-	{{range .PinnedNotes}}
+	{{range .PinnedProjectBoardNotes}}
 		<div class="note-card pinned-card {{if $canWriteProject}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-note="{{.ID}}">
 			{{template "repo/note" (dict
 				"BoardNote" .
@@ -87,7 +87,7 @@
 				<div class="project-column-header">
 					<div class="ui large label project-column-title gt-py-2">
 						<div class="ui small circular grey label project-column-issue-note-count">
-							{{.NumIssuesAndNotes ctx}}
+							{{.NumIssuesAndProjectBoardNotes ctx}}
 						</div>
 						{{.Title}}
 					</div>
@@ -219,9 +219,9 @@
 				<div class="divider"></div>
 
 				<div class="cards-wrapper">
-					{{if or $canWriteProject (index $.NotesMap .ID)}}
+					{{if or $canWriteProject (index $.ProjectBoardNotesMap .ID)}}
 						<div class="ui cards note-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
-							{{range (index $.NotesMap .ID)}}
+							{{range (index $.ProjectBoardNotesMap .ID)}}
 								<div class="note-card" data-note="{{.ID}}">
 									{{template "repo/note" (dict
 										"BoardNote" .

From 1c12d878e3144917744268b3f044b45c1464ff63 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Tue, 6 Feb 2024 20:52:21 +0100
Subject: [PATCH 26/46] refactor: LoadAttributes function

---
 models/project/note.go       | 55 +++++++++++++++++-------------------
 routers/web/repo/projects.go |  6 ++--
 2 files changed, 28 insertions(+), 33 deletions(-)

diff --git a/models/project/note.go b/models/project/note.go
index e66aa45372860..9a627807f5a0e 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -70,6 +70,10 @@ func GetProjectBoardNotesByIds(ctx context.Context, projectBoardNoteIDs []int64)
 		return nil, err
 	}
 
+	if err := projectBoardNoteList.LoadAttributes(ctx); err != nil {
+		return nil, err
+	}
+
 	return projectBoardNoteList, nil
 }
 
@@ -110,17 +114,8 @@ func ProjectBoardNotes(ctx context.Context, opts *ProjectBoardNotesOptions) (Pro
 		return nil, fmt.Errorf("unable to query project-board-notes: %w", err)
 	}
 
-	// @TODO: same code in `GetPinnedBoardNotes()` and should be used with `LoadAttributes()`
-	for _, note := range projectBoardNoteList {
-		creator := new(user_model.User)
-		has, err := db.GetEngine(ctx).ID(note.CreatorID).Get(creator)
-		if err != nil {
-			return nil, err
-		}
-		if !has {
-			return nil, user_model.ErrUserNotExist{UID: note.CreatorID}
-		}
-		note.Creator = creator
+	if err := projectBoardNoteList.LoadAttributes(ctx); err != nil {
+		return nil, err
 	}
 
 	return projectBoardNoteList, nil
@@ -132,11 +127,22 @@ func NewProjectBoardNote(ctx context.Context, projectBoardNote *ProjectBoardNote
 	return err
 }
 
-/* @TODO: make it work - markdown.RenderString should also be at this function
-// IsPinned returns if a BoardNote is pinned
-func (notes BoardNoteList) LoadAttributes() error {
+// LoadAttributes prerenders the markdown content and sets the creator
+func (projectBoardNoteList ProjectBoardNoteList) LoadAttributes(ctx context.Context) error {
+	for _, projectBoardNote := range projectBoardNoteList {
+		creator := new(user_model.User)
+		has, err := db.GetEngine(ctx).ID(projectBoardNote.CreatorID).Get(creator)
+		if err != nil {
+			return err
+		}
+		if !has {
+			return user_model.ErrUserNotExist{UID: projectBoardNote.CreatorID}
+		}
+		projectBoardNote.Creator = creator
+	}
+
 	return nil
-} */
+}
 
 var ErrBoardNoteMaxPinReached = util.NewInvalidArgumentErrorf("the max number of pinned project-board-notes has been readched")
 
@@ -273,32 +279,23 @@ func (projectBoardNote *ProjectBoardNote) MovePin(ctx context.Context, newPositi
 
 // GetPinnedProjectBoardNotes returns the pinned BaordNotes for the given Project
 func GetPinnedProjectBoardNotes(ctx context.Context, projectID int64) (ProjectBoardNoteList, error) {
-	notes := make(ProjectBoardNoteList, 0)
+	projectBoardNoteList := make(ProjectBoardNoteList, 0)
 
 	err := db.GetEngine(ctx).
 		Table("project_board_note").
 		Where("project_id = ?", projectID).
 		And("pin_order > 0").
 		OrderBy("pin_order").
-		Find(&notes)
+		Find(&projectBoardNoteList)
 	if err != nil {
 		return nil, err
 	}
 
-	// @TODO: same code in `BoardNotes()` and should be used with `LoadAttributes()`
-	for _, note := range notes {
-		creator := new(user_model.User)
-		has, err := db.GetEngine(ctx).ID(note.CreatorID).Get(creator)
-		if err != nil {
-			return nil, err
-		}
-		if !has {
-			return nil, user_model.ErrUserNotExist{UID: note.CreatorID}
-		}
-		note.Creator = creator
+	if err := projectBoardNoteList.LoadAttributes(ctx); err != nil {
+		return nil, err
 	}
 
-	return notes, nil
+	return projectBoardNoteList, nil
 }
 
 // GetLastEventTimestamp returns the last user visible event timestamp, either the creation or the update.
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 47e4e7bcc08ae..405c8ba305bb6 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -326,11 +326,10 @@ func ViewProject(ctx *context.Context) {
 
 	notesMap, err := project.LoadProjectBoardNotesFromBoardList(ctx, boards)
 	if err != nil {
-		ctx.ServerError("LoadNotesOfBoards", err)
+		ctx.ServerError("LoadProjectBoardNotesOfBoards", err)
 		return
 	}
 
-	// @TODO: maybe should be in BoardNote
 	for _, noteList := range notesMap {
 		for _, note := range noteList {
 			note.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
@@ -397,11 +396,10 @@ func ViewProject(ctx *context.Context) {
 
 	pinnedBoardNotes, err := project_model.GetPinnedProjectBoardNotes(ctx, project.ID)
 	if err != nil {
-		ctx.ServerError("GetPinnedBoardNotes", err)
+		ctx.ServerError("GetPinnedProjectBoardNotes", err)
 		return
 	}
 
-	// @TODO: maybe should be in BoardNote
 	for _, note := range pinnedBoardNotes {
 		note.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
 			Links: markup.Links{

From 7b28107636b96671f2c8293211051664e8051270 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Wed, 7 Feb 2024 15:14:05 +0100
Subject: [PATCH 27/46] fix: missed translations for form

---
 routers/web/repo/projects.go         | 11 +++++++----
 web_src/js/features/repo-projects.js |  4 ++--
 2 files changed, 9 insertions(+), 6 deletions(-)

diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 405c8ba305bb6..036c8819f4b0b 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -707,6 +707,7 @@ func MoveIssues(ctx *context.Context) {
 	form := &movedIssuesForm{}
 	if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
 		ctx.ServerError("DecodeMovedIssuesForm", err)
+		return
 	}
 
 	issueIDs := make([]int64, 0, len(form.Issues))
@@ -792,6 +793,7 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 		})
 		return nil, nil
 	}
+
 	return project, projectBoardNote
 }
 
@@ -910,6 +912,7 @@ func MoveProjectBoardNote(ctx *context.Context) {
 	form := &MovedProjectBoardNotesForm{}
 	if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
 		ctx.ServerError("DecodeMovedProjectBoardNotesForm", err)
+		return
 	}
 
 	projectBoardNoteIDs := make([]int64, 0, len(form.ProjectBoardNotes))
@@ -960,13 +963,13 @@ func PinProjectBoardNote(ctx *context.Context) {
 
 // PinBoardNote unpins the BoardNote
 func UnPinProjectBoardNote(ctx *context.Context) {
-	note, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
 		ctx.ServerError("GetBoardNoteByID", err)
 		return
 	}
 
-	err = note.Unpin(ctx)
+	err = projectBoardNote.Unpin(ctx)
 	if err != nil {
 		ctx.ServerError("UnpinProjectBoardNote", err)
 		return
@@ -992,13 +995,13 @@ func PinMoveProjectBoardNote(ctx *context.Context) {
 		return
 	}
 
-	note, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
 		ctx.ServerError("GetProjectBoardNoteByID", err)
 		return
 	}
 
-	err = note.MovePin(ctx, form.Position)
+	err = projectBoardNote.MovePin(ctx, form.Position)
 	if err != nil {
 		ctx.ServerError("MovePinProjectBoardNote", err)
 		return
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index 04a67a625ec98..c67d8ff4a6766 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -59,8 +59,8 @@ function moveNote({item, from, to, oldIndex}) {
   updateIssueAndNoteCount(to);
 
   const columnSorting = {
-    boardNotes: Array.from(columnCards, (card, i) => ({
-      boardNoteID: parseInt($(card).data('note')),
+    projectBoardNotes: Array.from(columnCards, (card, i) => ({
+      projectBoardNoteID: parseInt($(card).data('note')),
       sorting: i,
     })),
   };

From f3ce6317938d3e1c329efb5a894714edad97a06d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Wed, 7 Feb 2024 15:15:50 +0100
Subject: [PATCH 28/46] fix: projectBoardNote delete

---
 templates/repo/note.tmpl | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 585a77ce9260c..1b0705bb5e45f 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -93,7 +93,7 @@
 							<div class="header">
 								{{ctx.Locale.Tr "repo.projects.note.delete"}}
 							</div>
-							<form class="ui form form-fetch-action" action="delete">
+							<form class="ui form form-fetch-action" method="delete">
 								{{$.CsrfTokenHtml}}
 								<div class="content">
 									<label>

From 99cf2d110cf190f0a1e4c4ec08d9b2f669bc99c9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Wed, 7 Feb 2024 21:09:32 +0100
Subject: [PATCH 29/46] enhance: add tags to project-board-notes

---
 models/project/note.go            | 20 +++++---
 models/project/note_label.go      | 65 ++++++++++++++++++++++++
 models/project/project.go         | 23 ++++++++-
 modules/templates/helper.go       |  1 +
 modules/templates/util_render.go  | 20 ++++++++
 options/locale/locale_en-US.ini   | 11 ++--
 routers/web/repo/projects.go      | 83 +++++++++++++++++++++++++++++++
 templates/projects/view.tmpl      | 16 +++---
 templates/repo/note.tmpl          | 65 ++++++++++++++++++------
 web_src/css/features/projects.css |  8 +++
 10 files changed, 274 insertions(+), 38 deletions(-)
 create mode 100644 models/project/note_label.go

diff --git a/models/project/note.go b/models/project/note.go
index 9a627807f5a0e..084a17c7020a9 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -19,12 +19,13 @@ import (
 
 // ProjectBoardNote is used to represent a note on a boards
 type ProjectBoardNote struct {
-	ID              int64  `xorm:"pk autoincr"`
-	Title           string `xorm:"TEXT NOT NULL"`
-	Content         string `xorm:"LONGTEXT"`
-	RenderedContent string `xorm:"-"`
-	Sorting         int64  `xorm:"NOT NULL DEFAULT 0"`
-	PinOrder        int64  `xorm:"NOT NULL DEFAULT 0"`
+	ID              int64   `xorm:"pk autoincr"`
+	Title           string  `xorm:"TEXT NOT NULL"`
+	Content         string  `xorm:"LONGTEXT"`
+	RenderedContent string  `xorm:"-"`
+	Sorting         int64   `xorm:"NOT NULL DEFAULT 0"`
+	PinOrder        int64   `xorm:"NOT NULL DEFAULT 0"`
+	LabelIDs        []int64 `xorm:"-"` // can't be []*Label because of 'import cycle not allowed'
 
 	ProjectID int64            `xorm:"INDEX NOT NULL"`
 	BoardID   int64            `xorm:"INDEX NOT NULL"`
@@ -46,6 +47,7 @@ type ProjectBoardNotesOptions struct {
 
 func init() {
 	db.RegisterModel(new(ProjectBoardNote))
+	db.RegisterModel(new(ProjectBoardNoteLabel))
 }
 
 // GetProjectBoardNoteByID load notes assigned to the boards
@@ -56,7 +58,7 @@ func GetProjectBoardNoteByID(ctx context.Context, projectBoardNoteID int64) (*Pr
 	if err != nil {
 		return nil, err
 	} else if !has {
-		return nil, ErrProjectBoardNoteNotExist{BoardNoteID: projectBoardNoteID}
+		return nil, ErrProjectBoardNoteNotExist{ProjectBoardNoteID: projectBoardNoteID}
 	}
 
 	return projectBoardNote, nil
@@ -139,6 +141,10 @@ func (projectBoardNoteList ProjectBoardNoteList) LoadAttributes(ctx context.Cont
 			return user_model.ErrUserNotExist{UID: projectBoardNote.CreatorID}
 		}
 		projectBoardNote.Creator = creator
+
+		if err := projectBoardNote.LoadLabelIDs(ctx); err != nil {
+			return err
+		}
 	}
 
 	return nil
diff --git a/models/project/note_label.go b/models/project/note_label.go
new file mode 100644
index 0000000000000..71a2defd35703
--- /dev/null
+++ b/models/project/note_label.go
@@ -0,0 +1,65 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package project
+
+import (
+	"context"
+	"fmt"
+
+	"code.gitea.io/gitea/models/db"
+)
+
+// ProjectBoardNoteLabel represents an project-baord-note-label relation.
+type ProjectBoardNoteLabel struct {
+	ID                 int64 `xorm:"pk autoincr"`
+	ProjectBoardNoteID int64 `xorm:"INDEX NOT NULL"`
+	LabelID            int64 `xorm:"INDEX NOT NULL"`
+}
+
+// LoadLabels loads labels
+func (projectBoardNote *ProjectBoardNote) LoadLabelIDs(ctx context.Context) (err error) {
+	if projectBoardNote.LabelIDs != nil || len(projectBoardNote.LabelIDs) == 0 {
+		projectBoardNote.LabelIDs, err = GetLabelsByProjectBoardNoteID(ctx, projectBoardNote.ID)
+		if err != nil {
+			return fmt.Errorf("GetLabelsByProjectBoardNoteID [%d]: %w", projectBoardNote.ID, err)
+		}
+	}
+	return nil
+}
+
+// LoadLabels removes all labels from project-board-note
+func (projectBoardNote *ProjectBoardNote) RemoveAllLabels(ctx context.Context) error {
+	_, err := db.GetEngine(ctx).Where("project_board_note_id = ?", projectBoardNote.ID).Delete(ProjectBoardNoteLabel{})
+	return err
+}
+
+// LoadLabels add a label to project-board-note -> requires a valid labelID
+func (projectBoardNote *ProjectBoardNote) AddLabel(ctx context.Context, labelID int64) error {
+	_, err := db.GetEngine(ctx).Insert(ProjectBoardNoteLabel{
+		ProjectBoardNoteID: projectBoardNote.ID,
+		LabelID:            labelID,
+	})
+	return err
+}
+
+// LoadLabels removes a label from project-board-note
+func (projectBoardNote *ProjectBoardNote) RemoveLabelByID(ctx context.Context, labelID int64) error {
+	_, err := db.GetEngine(ctx).Delete(ProjectBoardNoteLabel{
+		ProjectBoardNoteID: projectBoardNote.ID,
+		LabelID:            labelID,
+	})
+	return err
+}
+
+// GetLabelsByProjectBoardNoteID returns all labelIDs that belong to given projectBoardNote by ID.
+func GetLabelsByProjectBoardNoteID(ctx context.Context, projectBoardNoteID int64) ([]int64, error) {
+	var labelIDs []int64
+	return labelIDs, db.GetEngine(ctx).
+		Table("label").
+		Cols("label.id").
+		Asc("label.name").
+		Where("project_board_note_label.project_board_note_id = ?", projectBoardNoteID).
+		Join("INNER", "project_board_note_label", "project_board_note_label.label_id = label.id").
+		Find(&labelIDs)
+}
diff --git a/models/project/project.go b/models/project/project.go
index a9fe317537f09..38621f449472a 100644
--- a/models/project/project.go
+++ b/models/project/project.go
@@ -87,7 +87,7 @@ func (err ErrProjectBoardNotExist) Unwrap() error {
 
 // ErrProjectBoardNoteNotExist represents a "ProjectBoardNotExist" kind of error.
 type ErrProjectBoardNoteNotExist struct {
-	BoardNoteID int64
+	ProjectBoardNoteID int64
 }
 
 // IsErrProjectBoardNoteNotExist checks if an error is a ErrProjectBoardNoteNotExist
@@ -97,13 +97,32 @@ func IsErrProjectBoardNoteNotExist(err error) bool {
 }
 
 func (err ErrProjectBoardNoteNotExist) Error() string {
-	return fmt.Sprintf("project-board-note does not exist [id: %d]", err.BoardNoteID)
+	return fmt.Sprintf("project-board-note does not exist [id: %d]", err.ProjectBoardNoteID)
 }
 
 func (err ErrProjectBoardNoteNotExist) Unwrap() error {
 	return util.ErrNotExist
 }
 
+// ErrProjectBoardNoteLabelNotExist represents a "ProjectBoardNotExist" kind of error.
+type ErrProjectBoardNoteLabelNotExist struct {
+	ProjectBoardNoteLabelID int64
+}
+
+// IsErrProjectBoardNoteLabelNotExist checks if an error is a ErrProjectBoardNoteLabelNotExist
+func IsErrProjectBoardNoteLabelNotExist(err error) bool {
+	_, ok := err.(ErrProjectBoardNoteLabelNotExist)
+	return ok
+}
+
+func (err ErrProjectBoardNoteLabelNotExist) Error() string {
+	return fmt.Sprintf("project-board-note-label does not exist [id: %d]", err.ProjectBoardNoteLabelID)
+}
+
+func (err ErrProjectBoardNoteLabelNotExist) Unwrap() error {
+	return util.ErrNotExist
+}
+
 // Project represents a project board
 type Project struct {
 	ID          int64                  `xorm:"pk autoincr"`
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index 96cdd9ca46c0c..36c5f74eb9d7e 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -165,6 +165,7 @@ func NewFuncMap() template.FuncMap {
 		"RenderMarkdownToHtml": RenderMarkdownToHtml,
 		"RenderLabel":          RenderLabel,
 		"RenderLabels":         RenderLabels,
+		"RenderLabelsFromIDs":  RenderLabelsFromIDs,
 
 		// -----------------------------------------------------------------
 		// misc
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
index 1d9635410b370..6c88a009cf6cd 100644
--- a/modules/templates/util_render.go
+++ b/modules/templates/util_render.go
@@ -224,3 +224,23 @@ func RenderLabels(ctx context.Context, labels []*issues_model.Label, repoLink st
 	htmlCode += "</span>"
 	return template.HTML(htmlCode)
 }
+
+func RenderLabelsFromIDs(ctx context.Context, labelIDs []int64, repoLink string) template.HTML {
+	labels, err := issues_model.GetLabelsByIDs(ctx, labelIDs)
+	if err != nil {
+		log.Error("GetLabelsByIDs", err)
+		return ""
+	}
+
+	htmlCode := `<span class="labels-list">`
+	for _, label := range labels {
+		// Protect against nil value in labels - shouldn't happen but would cause a panic if so
+		if label == nil {
+			continue
+		}
+		htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ",
+			repoLink, label.ID, RenderLabel(ctx, label))
+	}
+	htmlCode += "</span>"
+	return template.HTML(htmlCode)
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index e972e1a7d4575..073d7a1337e18 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1352,14 +1352,15 @@ projects.card_type.desc = "Card Previews"
 projects.card_type.images_and_text = "Images and Text"
 projects.card_type.text_only = "Text Only"
 
-projects.note.view = View Content
+projects.note.view = View Note
 projects.note.new = New Note
-projects.note.new_title = Title
-projects.note.content = Write a Note
+projects.note.title = Title
+projects.note.labels = Labels
+projects.note.no_label = No Label
+projects.note.description = Description
 projects.note.edit = Edit Note
-projects.note.edit_title = Title
 projects.note.delete = Delete Note
-projects.note.deletion_desc = Deleting removes the note from the project column. Continue?
+projects.note.deletion_description = Deleting removes the note from the project column. Continue?
 projects.note.created_by = created %[1]s by <a href="%[2]s">%[3]s</a>
 projects.note.updated_by = updated %[1]s by <a href="%[2]s">%[3]s</a>
 projects.note.max_pinned = You can't pin more notes
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 036c8819f4b0b..925e75f9dd149 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"net/http"
 	"net/url"
+	"slices"
 	"strings"
 
 	"code.gitea.io/gitea/models/db"
@@ -415,6 +416,13 @@ func ViewProject(ctx *context.Context) {
 		}
 	}
 
+	labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{})
+	if err != nil {
+		ctx.ServerError("GetLabelsByRepoID", err)
+		return
+	}
+
+	ctx.Data["Labels"] = labels
 	ctx.Data["IsProjectsPage"] = true
 	ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
 	ctx.Data["Project"] = project
@@ -821,6 +829,31 @@ func AddProjectBoardNoteToBoard(ctx *context.Context) {
 	ctx.JSONOK()
 }
 
+func findNewAndRemovedIDs(original []int64, updated []int64) (newValues, removedValues []int64) {
+	if original == nil {
+		return updated, nil
+	}
+	if updated == nil {
+		return nil, nil
+	}
+
+	for _, v := range updated {
+		if !slices.Contains(original, v) {
+			// If the value is not in the original map, it's new
+			newValues = append(newValues, v)
+		}
+	}
+
+	for _, v := range original {
+		if !slices.Contains(updated, v) {
+			// If the value is not in the updated map, it's removed
+			removedValues = append(removedValues, v)
+		}
+	}
+
+	return newValues, removedValues
+}
+
 func EditProjectBoardNote(ctx *context.Context) {
 	form := web.GetForm(ctx).(*forms.ProjectBoardNoteForm)
 	_, projectBoardNote := checkProjectBoardNoteChangePermissions(ctx)
@@ -831,11 +864,61 @@ func EditProjectBoardNote(ctx *context.Context) {
 	projectBoardNote.Title = form.Title
 	projectBoardNote.Content = form.Content
 
+	err := projectBoardNote.LoadLabelIDs(ctx)
+	if err != nil {
+		ctx.ServerError("LoadLabelIDs", err)
+	}
+
 	if err := project_model.UpdateProjectBoardNote(ctx, projectBoardNote); err != nil {
 		ctx.ServerError("UpdateProjectBoardNote", err)
 		return
 	}
 
+	// LabelIDs is send without parentheses - maybe because of multipart/form-data
+	labelIdsString := "[" + ctx.Req.FormValue("labelIds") + "]"
+	var labelIDs []int64
+	if err := json.Unmarshal([]byte(labelIdsString), &labelIDs); err != nil {
+		ctx.ServerError("Unmarshal", err)
+	}
+
+	newLabelIDs, removedLabelIDs := findNewAndRemovedIDs(projectBoardNote.LabelIDs, labelIDs)
+
+	for _, labelID := range newLabelIDs {
+		label, err := issues_model.GetLabelByID(ctx, labelID)
+		if err != nil {
+			if issues_model.IsErrLabelNotExist(err) {
+				ctx.Error(http.StatusNotFound, "GetLabelByID")
+			} else {
+				ctx.ServerError("GetLabelByID", err)
+			}
+			return
+		}
+
+		err = projectBoardNote.AddLabel(ctx, label.ID)
+		if err != nil {
+			ctx.ServerError("AddLabel", err)
+			return
+		}
+	}
+
+	for _, labelID := range removedLabelIDs {
+		label, err := issues_model.GetLabelByID(ctx, labelID)
+		if err != nil {
+			if issues_model.IsErrLabelNotExist(err) {
+				ctx.Error(http.StatusNotFound, "GetLabelByID")
+			} else {
+				ctx.ServerError("GetLabelByID", err)
+			}
+			return
+		}
+
+		err = projectBoardNote.RemoveLabelByID(ctx, label.ID)
+		if err != nil {
+			ctx.ServerError("RemoveLabelByID", err)
+			return
+		}
+	}
+
 	ctx.JSONOK()
 }
 
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 1da3dbae7ea43..d9db070a634bc 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -6,11 +6,9 @@
 		<div class="note-card pinned-card {{if $canWriteProject}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-note="{{.ID}}">
 			{{template "repo/note" (dict
 				"BoardNote" .
+				"root" $
 				"CanWriteProjects" $canWriteProject
-				"Link" $.Link
-				"RepoLink" $.RepoLink
-				"ProjectID" $.Project.ID
-				"CsrfTokenHtml" $.CsrfTokenHtml)
+				"RenderModals" false)
 			}}
 		</div>
 	{{end}}
@@ -143,7 +141,7 @@
 										<div class="content">
 											{{$.CsrfTokenHtml}}
 											<div class="required field">
-												<label for="new-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.new_title"}}</label>
+												<label for="new-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.title"}}</label>
 												<input id="new-project-column-note-title-{{.ID}}" name="title" maxlength="255" autofocus required>
 											</div>
 
@@ -152,7 +150,7 @@
 													"MarkdownPreviewUrl" (print $.RepoLink "/markup")
 													"MarkdownPreviewContext" $.RepoLink
 													"TextareaName" "content"
-													"TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.note.content")
+													"TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.note.description")
 												)}}
 											</div>
 										</div>
@@ -225,11 +223,9 @@
 								<div class="note-card" data-note="{{.ID}}">
 									{{template "repo/note" (dict
 										"BoardNote" .
+										"root" $
 										"CanWriteProjects" $canWriteProject
-										"Link" $.Link
-										"RepoLink" $.RepoLink
-										"ProjectID" $.Project.ID
-										"CsrfTokenHtml" $.CsrfTokenHtml)
+										"RenderModals" true)
 									}}
 								</div>
 							{{end}}
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 1b0705bb5e45f..daf04f8a4bd8b 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -19,11 +19,11 @@
 					{{if $.CanWriteProjects}}
 						<button class="ui item show-modal button"
 							data-modal="#edit-project-column-note-modal-{{.ID}}"
-							data-modal-form.action="{{$.Link}}/{{.BoardID}}/note/{{.ID}}">
+							data-modal-form.action="{{$.root.Link}}/{{.BoardID}}/note/{{.ID}}">
 								{{svg "octicon-pencil"}}
 								{{ctx.Locale.Tr "repo.projects.note.edit"}}
 						</button>
-						<a class="ui item button create-project-issue-from-note" href="{{$.RepoLink}}/issues/new/choose?project={{$.ProjectID}}">
+						<a class="ui item button create-project-issue-from-note" href="{{$.root.RepoLink}}/issues/new/choose?project={{$.root.Project.ID}}">
 							{{svg "octicon-issue-opened"}}
 							{{ctx.Locale.Tr "repo.issues.new"}}
 						</a>
@@ -31,7 +31,7 @@
 						<button class="ui item button {{if $notPinnedAndNotAllowedToPin}}disabled{{end}} pin-project-column-note"
 							{{if $notPinnedAndNotAllowedToPin}}style="pointer-events: unset !important"{{end}}
 							data-method="{{if .IsPinned}}delete{{else}}post{{end}}"
-							data-url="{{$.Link}}/{{.BoardID}}/note/{{.ID}}/pin"
+							data-url="{{$.root.Link}}/{{.BoardID}}/note/{{.ID}}/pin"
 							{{if $notPinnedAndNotAllowedToPin}}data-tooltip-content="{{ctx.Locale.Tr "repo.projects.note.max_pinned"}}"{{end}}>
 								{{if .IsPinned}}
 									{{svg "octicon-pin-slash"}}
@@ -43,7 +43,7 @@
 						</button>
 						<button class="ui item show-modal button"
 							data-modal="#delete-project-column-note-modal-{{.ID}}"
-							data-modal-form.action="{{$.Link}}/{{.BoardID}}/note/{{.ID}}">
+							data-modal-form.action="{{$.root.Link}}/{{.BoardID}}/note/{{.ID}}">
 								{{svg "octicon-trash"}}
 								{{ctx.Locale.Tr "repo.projects.note.delete"}}
 						</button>
@@ -53,34 +53,68 @@
 						<div class="header">
 							{{ctx.Locale.Tr "repo.projects.note.view"}}
 						</div>
-						<div class="content">
-							{{Str2html .RenderedContent}}
+						<div class="content ui form">
+							<div class="field">
+								<label class="gt-font-16">{{ctx.Locale.Tr "repo.projects.note.title"}}</label>
+								{{.Title}}
+							</div>
+
+							<div class="field">
+								<label class="gt-font-16">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
+								{{if .LabelIDs}}
+									{{RenderLabelsFromIDs ctx .LabelIDs $.root.Link}}
+								{{else}}
+									{{ctx.Locale.Tr "repo.projects.note.no_label"}}
+								{{end}}
+							</div>
+
+							<div class="field">
+								<label class="gt-font-16">{{ctx.Locale.Tr "repo.projects.note.description"}}</label>
+								{{Str2html .RenderedContent}}
+							</div>
 						</div>
 						<div class="actions">
 							<button class="ui cancel button">{{ctx.Locale.Tr "repo.projects.close"}}</button>
 						</div>
 					</div>
 
-					{{if $.CanWriteProjects}}
+					{{if and $.RenderModals $.CanWriteProjects}}
 						<div class="ui modal" id="edit-project-column-note-modal-{{.ID}}">
 							<div class="header">
 								{{ctx.Locale.Tr "repo.projects.note.edit"}}
 							</div>
 							<form class="ui form form-fetch-action" method="put">
 								<div class="content">
-									{{$.CsrfTokenHtml}}
+									{{$.root.CsrfTokenHtml}}
 									<div class="required field">
-										<label for="edit-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.edit_title"}}</label>
+										<label for="edit-project-column-note-title-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.title"}}</label>
 										<input id="edit-project-column-note-title-{{.ID}}" name="title" value="{{.Title}}" maxlength="255" autofocus required>
 									</div>
 
+									<div class="field">
+										<label for="edit-project-column-note-label-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
+										<div class="ui fluid selection dropdown multiple clearable">
+											<input type="hidden" id="edit-project-column-note-label-{{.ID}}" name="labelIds" value="{{range $index, $val := .LabelIDs}}{{if ne $index 0}},{{end}}{{$val}}{{end}}">
+											{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+											<div class="default text"></div>
+											<div class="menu">
+												{{range $.root.Labels}}
+													<div class="item vertical gt-gap-y-2" data-value="{{.ID}}">
+														<small class="desc">{{.Description | RenderEmoji ctx}}</small>
+														<span>{{RenderLabel ctx .}}</span>
+													</div>
+												{{end}}
+											</div>
+										</div>
+									</div>
+
 									<div class="field">
 										{{template "shared/combomarkdowneditor" (dict
-											"MarkdownPreviewUrl" (print $.RepoLink "/markup")
-											"MarkdownPreviewContext" $.RepoLink
+											"MarkdownPreviewUrl" (print $.root.RepoLink "/markup")
+											"MarkdownPreviewContext" $.root.RepoLink
 											"TextareaName" "content"
 											"TextareaContent" .Content
-											"TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.note.content")
+											"TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.note.description")
 										)}}
 									</div>
 								</div>
@@ -94,10 +128,10 @@
 								{{ctx.Locale.Tr "repo.projects.note.delete"}}
 							</div>
 							<form class="ui form form-fetch-action" method="delete">
-								{{$.CsrfTokenHtml}}
+								{{$.root.CsrfTokenHtml}}
 								<div class="content">
 									<label>
-										{{ctx.Locale.Tr "repo.projects.note.deletion_desc"}}
+										{{ctx.Locale.Tr "repo.projects.note.deletion_description"}}
 									</label>
 								</div>
 								{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
@@ -124,5 +158,8 @@
 			<span class="gt-vm">{{.GetTasksDone}} / {{$tasks}}</span>
 		</div>
 	{{end}}
+	{{if .LabelIDs}}
+		{{RenderLabelsFromIDs ctx .LabelIDs $.root.Link}}
+	{{end}}
 </div>
 {{end}}
diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index eb9a27c3aa8a4..7ff3ff6dc3299 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -3,6 +3,14 @@
   overflow-y: auto;
 }
 
+.ui.modal .ui.selection {
+  flex-wrap: wrap;
+}
+
+.ui.modal .ui.selection > a > .desc {
+  display: none;
+}
+
 #pinned-notes {
   display: grid;
   grid-template-columns: repeat(3, 1fr);

From 1bd15a477a02c7d539fd65decde0429501e5594b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Wed, 7 Feb 2024 22:39:35 +0100
Subject: [PATCH 30/46] fix: init markdownEditor on pinned notes

---
 web_src/js/features/repo-projects.js | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index c67d8ff4a6766..402549b7c9fca 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
 import {useLightTextOnBackground} from '../utils/color.js';
 import tinycolor from 'tinycolor2';
 import {createSortable} from '../modules/sortable.js';
-import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
+import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
 
 const {csrfToken} = window.config;
 
@@ -172,7 +172,7 @@ export function initRepoProject() {
     return;
   }
 
-  const modalButtons = mainContent.find('.project-column button[data-modal]');
+  const modalButtons = mainContent.find(':where(.project-column, #pinned-notes) button[data-modal]');
   modalButtons.each(function() {
     const modalButton = $(this);
     const modalId = modalButton.data('modal');
@@ -181,6 +181,8 @@ export function initRepoProject() {
     if (!markdownEditor.length) return;
 
     modalButton.one('click', () => {
+      const comboMarkdownEditor = getComboMarkdownEditor(markdownEditor);
+      if (comboMarkdownEditor) return; // only init once
       initComboMarkdownEditor(markdownEditor, {easyMDEOptions: {maxHeight: '50vh', minHeight: '50vh'}});
     });
   });

From dd7cb774dc0e9fee57b08a87fc89c4c61498f6e8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Wed, 7 Feb 2024 22:40:48 +0100
Subject: [PATCH 31/46] enhance: select labels while creating note

---
 routers/web/repo/projects.go | 36 ++++++++++++++++++++++++++++++++++--
 templates/projects/view.tmpl | 17 +++++++++++++++++
 2 files changed, 51 insertions(+), 2 deletions(-)

diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 925e75f9dd149..fe302a0e4d455 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -814,18 +814,50 @@ func AddProjectBoardNoteToBoard(ctx *context.Context) {
 		return
 	}
 
-	if err := project_model.NewProjectBoardNote(ctx, &project_model.ProjectBoardNote{
+	// LabelIDs is send without parentheses - maybe because of multipart/form-data
+	labelIdsString := "[" + ctx.Req.FormValue("labelIds") + "]"
+	var labelIDs []int64
+	if err := json.Unmarshal([]byte(labelIdsString), &labelIDs); err != nil {
+		ctx.ServerError("Unmarshal", err)
+	}
+
+	// check that all LabelsIDs are valid
+	for _, labelID := range labelIDs {
+		_, err := issues_model.GetLabelByID(ctx, labelID)
+		if err != nil {
+			if issues_model.IsErrLabelNotExist(err) {
+				ctx.Error(http.StatusNotFound, "GetLabelByID")
+			} else {
+				ctx.ServerError("GetLabelByID", err)
+			}
+			return
+		}
+	}
+
+	projectBoardNote := project_model.ProjectBoardNote{
 		Title:   form.Title,
 		Content: form.Content,
 
 		ProjectID: ctx.ParamsInt64(":id"),
 		BoardID:   ctx.ParamsInt64(":boardID"),
 		CreatorID: ctx.Doer.ID,
-	}); err != nil {
+	}
+	err := project_model.NewProjectBoardNote(ctx, &projectBoardNote)
+	if err != nil {
 		ctx.ServerError("NewProjectBoardNote", err)
 		return
 	}
 
+	if len(labelIDs) > 0 {
+		for _, labelID := range labelIDs {
+			err := projectBoardNote.AddLabel(ctx, labelID)
+			if err != nil {
+				ctx.ServerError("AddLabel", err)
+				return
+			}
+		}
+	}
+
 	ctx.JSONOK()
 }
 
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index d9db070a634bc..f85cecc48bf41 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -145,6 +145,23 @@
 												<input id="new-project-column-note-title-{{.ID}}" name="title" maxlength="255" autofocus required>
 											</div>
 
+											<div class="field">
+												<label for="new-project-column-note-label-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
+												<div class="ui fluid selection dropdown multiple clearable">
+													<input type="hidden" id="new-project-column-note-label-{{.ID}}" name="labelIds">
+													{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+													<div class="default text"></div>
+													<div class="menu">
+														{{range $.Labels}}
+															<div class="item vertical gt-gap-y-2" data-value="{{.ID}}">
+																<small class="desc">{{.Description | RenderEmoji ctx}}</small>
+																<span>{{RenderLabel ctx .}}</span>
+															</div>
+														{{end}}
+													</div>
+												</div>
+											</div>
+
 											<div class="field">
 												{{template "shared/combomarkdowneditor" (dict
 													"MarkdownPreviewUrl" (print $.RepoLink "/markup")

From 0c770143cb893f6b1c58e0b9264550cb43efac41 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Wed, 7 Feb 2024 22:41:36 +0100
Subject: [PATCH 32/46] fix: unique note_label

---
 models/project/note_label.go | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/models/project/note_label.go b/models/project/note_label.go
index 71a2defd35703..562d45670eaa6 100644
--- a/models/project/note_label.go
+++ b/models/project/note_label.go
@@ -13,8 +13,8 @@ import (
 // ProjectBoardNoteLabel represents an project-baord-note-label relation.
 type ProjectBoardNoteLabel struct {
 	ID                 int64 `xorm:"pk autoincr"`
-	ProjectBoardNoteID int64 `xorm:"INDEX NOT NULL"`
-	LabelID            int64 `xorm:"INDEX NOT NULL"`
+	ProjectBoardNoteID int64 `xorm:"UNIQUE(s) NOT NULL"`
+	LabelID            int64 `xorm:"UNIQUE(s) NOT NULL"`
 }
 
 // LoadLabels loads labels

From 313016ebb097f9e243c3f8dd2efa8787d1bd8349 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Wed, 7 Feb 2024 22:46:01 +0100
Subject: [PATCH 33/46] style: break note card title

---
 web_src/css/repo/note-card.css | 1 +
 1 file changed, 1 insertion(+)

diff --git a/web_src/css/repo/note-card.css b/web_src/css/repo/note-card.css
index 5cf2b8dc3fc16..b3bca02179733 100644
--- a/web_src/css/repo/note-card.css
+++ b/web_src/css/repo/note-card.css
@@ -21,6 +21,7 @@
 }
 
 .note-card-title {
+  word-break: break-word;
   flex: 1;
   font-size: 18px;
   margin-left: 4px;

From c87b630ac038fcde010179d7974b4a44bcd2bceb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Wed, 7 Feb 2024 23:04:48 +0100
Subject: [PATCH 34/46] fix: issue content was set with old data

---
 web_src/js/features/repo-issue-new.js | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/web_src/js/features/repo-issue-new.js b/web_src/js/features/repo-issue-new.js
index 5aaa2bba21448..48047129a99f9 100644
--- a/web_src/js/features/repo-issue-new.js
+++ b/web_src/js/features/repo-issue-new.js
@@ -9,6 +9,9 @@ export function initRepoIssueNew() {
   const boardNoteTitle = sessionStorage.getItem('board-note-title');
   const boardNoteContent = sessionStorage.getItem('board-note-content');
 
+  sessionStorage.removeItem('board-note-title');
+  sessionStorage.removeItem('board-note-content');
+
   if (boardNoteTitle) {
     const issueTitle = newIssuePage.find('#issue_title');
     issueTitle.val(boardNoteTitle);

From be88dedfd44faec79f904e9359bff23df7683ab0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Thu, 8 Feb 2024 01:32:44 +0100
Subject: [PATCH 35/46] enhance: link milestone to note

---
 models/project/note.go                      |  2 ++
 modules/templates/helper.go                 |  2 ++
 modules/templates/util_render.go            | 22 +++++++++++++
 routers/web/repo/projects.go                | 27 ++++++++++++++--
 services/forms/repo_form.go                 |  5 +--
 templates/projects/milestone_selection.tmpl | 36 +++++++++++++++++++++
 templates/projects/view.tmpl                |  5 +++
 templates/repo/issue/card.tmpl              |  9 ++----
 templates/repo/note.tmpl                    | 10 ++++++
 9 files changed, 107 insertions(+), 11 deletions(-)
 create mode 100644 templates/projects/milestone_selection.tmpl

diff --git a/models/project/note.go b/models/project/note.go
index 084a17c7020a9..d6ee985e69cce 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -26,6 +26,7 @@ type ProjectBoardNote struct {
 	Sorting         int64   `xorm:"NOT NULL DEFAULT 0"`
 	PinOrder        int64   `xorm:"NOT NULL DEFAULT 0"`
 	LabelIDs        []int64 `xorm:"-"` // can't be []*Label because of 'import cycle not allowed'
+	MilestoneID     int64   `xorm:"INDEX"`
 
 	ProjectID int64            `xorm:"INDEX NOT NULL"`
 	BoardID   int64            `xorm:"INDEX NOT NULL"`
@@ -333,6 +334,7 @@ func UpdateProjectBoardNote(ctx context.Context, projectBoardNote *ProjectBoardN
 
 	fieldToUpdate = append(fieldToUpdate, "title")
 	fieldToUpdate = append(fieldToUpdate, "content")
+	fieldToUpdate = append(fieldToUpdate, "milestone_id")
 
 	_, err := db.GetEngine(ctx).ID(projectBoardNote.ID).Cols(fieldToUpdate...).Update(projectBoardNote)
 	return err
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index 36c5f74eb9d7e..7b8edc4849d73 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -167,6 +167,8 @@ func NewFuncMap() template.FuncMap {
 		"RenderLabels":         RenderLabels,
 		"RenderLabelsFromIDs":  RenderLabelsFromIDs,
 
+		"RenderMilestone": RenderMilestone,
+
 		// -----------------------------------------------------------------
 		// misc
 		"ShortSha":                 base.ShortSha,
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
index 6c88a009cf6cd..e7208116f55f7 100644
--- a/modules/templates/util_render.go
+++ b/modules/templates/util_render.go
@@ -15,11 +15,13 @@ import (
 	"unicode"
 
 	issues_model "code.gitea.io/gitea/models/issues"
+	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/modules/emoji"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/markup"
 	"code.gitea.io/gitea/modules/markup/markdown"
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/svg"
 	"code.gitea.io/gitea/modules/util"
 )
 
@@ -244,3 +246,23 @@ func RenderLabelsFromIDs(ctx context.Context, labelIDs []int64, repoLink string)
 	htmlCode += "</span>"
 	return template.HTML(htmlCode)
 }
+
+func RenderMilestone(ctx context.Context, milestoneID int64, repoID int64, classes ...string) template.HTML {
+	repo, err := repo_model.GetRepositoryByID(ctx, repoID)
+	if err != nil {
+		log.Error("GetRepositoryByID", err)
+		return ""
+	}
+
+	milestone, err := issues_model.GetMilestoneByRepoID(ctx, repo.ID, milestoneID)
+	if err != nil {
+		log.Error("GetMilestoneByRepoID", err)
+		return ""
+	}
+
+	htmlCode := fmt.Sprintf("<a class='milestone %s' href='%s/milestone/%d'>", strings.Join(classes, " "), repo.Link(), milestone.ID)
+	htmlCode += string(svg.RenderHTML("octicon-milestone", 16, "gt-mr-2 gt-vm"))
+	htmlCode += fmt.Sprintf("<span class'gt-vm'>%s</span>", milestone.Name)
+	htmlCode += "</a>"
+	return template.HTML(htmlCode)
+}
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index fe302a0e4d455..b6ccd3dd39946 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -421,8 +421,27 @@ func ViewProject(ctx *context.Context) {
 		ctx.ServerError("GetLabelsByRepoID", err)
 		return
 	}
-
 	ctx.Data["Labels"] = labels
+
+	milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
+		RepoID: ctx.Repo.Repository.ID,
+	})
+	if err != nil {
+		ctx.ServerError("GetAllRepoMilestones", err)
+		return
+	}
+
+	openMilestones, closedMilestones := issues_model.MilestoneList{}, issues_model.MilestoneList{}
+	for _, milestone := range milestones {
+		if milestone.IsClosed {
+			closedMilestones = append(closedMilestones, milestone)
+		} else {
+			openMilestones = append(openMilestones, milestone)
+		}
+	}
+	ctx.Data["OpenMilestones"] = openMilestones
+	ctx.Data["ClosedMilestones"] = closedMilestones
+
 	ctx.Data["IsProjectsPage"] = true
 	ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
 	ctx.Data["Project"] = project
@@ -835,8 +854,9 @@ func AddProjectBoardNoteToBoard(ctx *context.Context) {
 	}
 
 	projectBoardNote := project_model.ProjectBoardNote{
-		Title:   form.Title,
-		Content: form.Content,
+		Title:       form.Title,
+		Content:     form.Content,
+		MilestoneID: form.MilestoneID,
 
 		ProjectID: ctx.ParamsInt64(":id"),
 		BoardID:   ctx.ParamsInt64(":boardID"),
@@ -895,6 +915,7 @@ func EditProjectBoardNote(ctx *context.Context) {
 
 	projectBoardNote.Title = form.Title
 	projectBoardNote.Content = form.Content
+	projectBoardNote.MilestoneID = form.MilestoneID
 
 	err := projectBoardNote.LoadLabelIDs(ctx)
 	if err != nil {
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index 2f286f1a2116c..4db0306643870 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -535,8 +535,9 @@ type EditProjectBoardForm struct {
 
 // ProjectBoardNoteForm is a form for editing/creating a note to a board
 type ProjectBoardNoteForm struct {
-	Title   string `binding:"Required;MaxSize(255)"`
-	Content string
+	Title       string `binding:"Required;MaxSize(255)"`
+	Content     string
+	MilestoneID int64 `form:"milestoneId"`
 }
 
 //    _____  .__.__                   __
diff --git a/templates/projects/milestone_selection.tmpl b/templates/projects/milestone_selection.tmpl
new file mode 100644
index 0000000000000..be9314244e3bf
--- /dev/null
+++ b/templates/projects/milestone_selection.tmpl
@@ -0,0 +1,36 @@
+<div class="ui fluid selection dropdown clearable">
+	<input type="hidden" id="edit-project-column-note-milestone-{{.ID}}" name="milestoneId" {{if and .MilestoneID (gt .MilestoneID 0)}}value="{{.MilestoneID}}"{{end}}>
+	{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+	{{svg "octicon-x" 14 "remove icon"}}
+	<div class="default text"></div>
+	<div class="menu">
+		{{if and (not .OpenMilestones) (not .ClosedMilestones)}}
+			<div class="disabled item">
+				{{ctx.Locale.Tr "repo.projects.note.no_items"}}
+			</div>
+		{{else}}
+			{{if .OpenMilestones}}
+				<div class="divider"></div>
+				<div class="header">
+					{{ctx.Locale.Tr "repo.projects.note.open_milestone"}}
+				</div>
+				{{range .OpenMilestones}}
+					<div class="item" data-value="{{.ID}}">
+						{{RenderMilestone ctx .ID $.Repository.ID}}
+					</div>
+				{{end}}
+			{{end}}
+			{{if .ClosedMilestones}}
+				{{if gt (len .OpenMilestones) 0}}<div class="divider"></div>{{end}}
+				<div class="header">
+					{{ctx.Locale.Tr "repo.projects.note.closed_milestone"}}
+				</div>
+				{{range .ClosedMilestones}}
+					<div class="item" data-value="{{.ID}}">
+						{{RenderMilestone ctx .ID $.Repository.ID}}
+					</div>
+				{{end}}
+			{{end}}
+		{{end}}
+	</div>
+</div>
\ No newline at end of file
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index f85cecc48bf41..a315f09618629 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -162,6 +162,11 @@
 												</div>
 											</div>
 
+											<div class="field">
+												<label for="new-project-column-note-milestone-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.milestone"}}</label>
+												{{template "projects/milestone_selection" $}}
+											</div>
+
 											<div class="field">
 												{{template "shared/combomarkdowneditor" (dict
 													"MarkdownPreviewUrl" (print $.RepoLink "/markup")
diff --git a/templates/repo/issue/card.tmpl b/templates/repo/issue/card.tmpl
index 14d08fc0ef9cf..beaff28d1d7d4 100644
--- a/templates/repo/issue/card.tmpl
+++ b/templates/repo/issue/card.tmpl
@@ -32,12 +32,9 @@
 			</span>
 		</div>
 		{{if .MilestoneID}}
-		<div class="meta gt-my-2">
-			<a class="milestone" href="{{.Repo.Link}}/milestone/{{.MilestoneID}}">
-				{{svg "octicon-milestone" 16 "gt-mr-2 gt-vm"}}
-				<span class="gt-vm">{{.Milestone.Name}}</span>
-			</a>
-		</div>
+			<div class="meta gt-my-2">
+				{{RenderMilestone ctx .MilestoneID .Repo.ID}}
+			</div>
 		{{end}}
 		{{if $.Page.LinkedPRs}}
 		{{range index $.Page.LinkedPRs .ID}}
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index daf04f8a4bd8b..610b66242ea4a 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -108,6 +108,11 @@
 										</div>
 									</div>
 
+									<div class="field">
+										<label for="edit-project-column-note-milestone-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.milestone"}}</label>
+										{{template "projects/milestone_selection" (dict "." $.root "MilestoneID" .MilestoneID)}}
+									</div>
+
 									<div class="field">
 										{{template "shared/combomarkdowneditor" (dict
 											"MarkdownPreviewUrl" (print $.root.RepoLink "/markup")
@@ -151,6 +156,11 @@
 			{{ctx.Locale.Tr .GetLastEventLabel $timeStr (.Creator.HomeLink | Escape) (.Creator.GetDisplayName | Escape) | Safe}}
 		</span>
 	</div>
+	{{if .MilestoneID}}
+		<div class="meta gt-my-2">
+			{{RenderMilestone ctx .MilestoneID $.root.Repository.ID}}
+		</div>
+	{{end}}
 	{{$tasks := .GetTasks}}
 	{{if gt $tasks 0}}
 		<div class="meta">

From 1f1c9971c9c8f243bda1445ca318ad41cdee24e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Thu, 8 Feb 2024 15:01:25 +0100
Subject: [PATCH 36/46] permissions: only admin and creator can edit notes

---
 models/project/note.go       | 13 -------------
 routers/web/repo/projects.go |  8 ++++++++
 templates/repo/note.tmpl     | 34 +++++++++++++++++++---------------
 3 files changed, 27 insertions(+), 28 deletions(-)

diff --git a/models/project/note.go b/models/project/note.go
index d6ee985e69cce..7154e143863c0 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -305,19 +305,6 @@ func GetPinnedProjectBoardNotes(ctx context.Context, projectID int64) (ProjectBo
 	return projectBoardNoteList, nil
 }
 
-// GetLastEventTimestamp returns the last user visible event timestamp, either the creation or the update.
-func (projectBoardNote *ProjectBoardNote) GetLastEventTimestamp() timeutil.TimeStamp {
-	return max(projectBoardNote.CreatedUnix, projectBoardNote.UpdatedUnix)
-}
-
-// GetLastEventLabel returns the localization label for the current note.
-func (projectBoardNote *ProjectBoardNote) GetLastEventLabel() string {
-	if projectBoardNote.UpdatedUnix > projectBoardNote.CreatedUnix {
-		return "repo.projects.note.updated_by"
-	}
-	return "repo.projects.note.created_by"
-}
-
 // GetTasks returns the amount of tasks in the project-board-notes content
 func (projectBoardNote *ProjectBoardNote) GetTasks() int {
 	return len(markdown.MarkdownTasksRegex.FindAllStringIndex(projectBoardNote.Content, -1))
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index b6ccd3dd39946..7b54383d1b970 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -807,6 +807,14 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 		}
 		return nil, nil
 	}
+
+	if !ctx.Doer.IsAdmin && ctx.Doer.ID != projectBoardNote.CreatorID {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only the creator or an admin can perform this action.",
+		})
+		return nil, nil
+	}
+
 	if projectBoardNote.ProjectID != project.ID {
 		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
 			"message": fmt.Sprintf("ProjectBoardNote[%d] is not in Project[%d] as expected", projectBoardNote.ID, project.ID),
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 610b66242ea4a..31a97f1d445e7 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -17,17 +17,19 @@
 							{{ctx.Locale.Tr "repo.projects.note.view"}}
 					</button>
 					{{if $.CanWriteProjects}}
-						<button class="ui item show-modal button"
-							data-modal="#edit-project-column-note-modal-{{.ID}}"
-							data-modal-form.action="{{$.root.Link}}/{{.BoardID}}/note/{{.ID}}">
-								{{svg "octicon-pencil"}}
-								{{ctx.Locale.Tr "repo.projects.note.edit"}}
-						</button>
+						{{if eq $.root.Context.Doer.ID .CreatorID}}
+							<button class="ui item show-modal button"
+								data-modal="#edit-project-column-note-modal-{{.ID}}"
+								data-modal-form.action="{{$.root.Link}}/{{.BoardID}}/note/{{.ID}}">
+									{{svg "octicon-pencil"}}
+									{{ctx.Locale.Tr "repo.projects.note.edit"}}
+							</button>
+						{{end}}
 						<a class="ui item button create-project-issue-from-note" href="{{$.root.RepoLink}}/issues/new/choose?project={{$.root.Project.ID}}">
 							{{svg "octicon-issue-opened"}}
 							{{ctx.Locale.Tr "repo.issues.new"}}
 						</a>
-						{{$notPinnedAndNotAllowedToPin := and (not .IsPinned) (not (.IsNewPinAllowed ctx))}}
+						{{$notPinnedAndNotAllowedToPin := and (not .IsPinned) (not (.IsNewPinAllowed ctx)) (or $.root.Context.Doer.IsAdmin (eq $.root.Context.Doer.ID .CreatorID))}}
 						<button class="ui item button {{if $notPinnedAndNotAllowedToPin}}disabled{{end}} pin-project-column-note"
 							{{if $notPinnedAndNotAllowedToPin}}style="pointer-events: unset !important"{{end}}
 							data-method="{{if .IsPinned}}delete{{else}}post{{end}}"
@@ -41,12 +43,14 @@
 									{{ctx.Locale.Tr "pin"}}
 								{{end}}
 						</button>
-						<button class="ui item show-modal button"
-							data-modal="#delete-project-column-note-modal-{{.ID}}"
-							data-modal-form.action="{{$.root.Link}}/{{.BoardID}}/note/{{.ID}}">
-								{{svg "octicon-trash"}}
-								{{ctx.Locale.Tr "repo.projects.note.delete"}}
-						</button>
+						{{if eq $.root.Context.Doer.ID .CreatorID}}
+							<button class="ui item show-modal button"
+								data-modal="#delete-project-column-note-modal-{{.ID}}"
+								data-modal-form.action="{{$.root.Link}}/{{.BoardID}}/note/{{.ID}}">
+									{{svg "octicon-trash"}}
+									{{ctx.Locale.Tr "repo.projects.note.delete"}}
+							</button>
+						{{end}}
 					{{end}}
 
 					<div class="ui modal" id="view-project-column-note-modal-{{.ID}}">
@@ -152,8 +156,8 @@
 	</div>
 	<div class="meta">
 		<span class="text light grey muted-links">
-			{{$timeStr := TimeSinceUnix .GetLastEventTimestamp ctx.Locale}}
-			{{ctx.Locale.Tr .GetLastEventLabel $timeStr (.Creator.HomeLink | Escape) (.Creator.GetDisplayName | Escape) | Safe}}
+			{{$timeStr := TimeSinceUnix .CreatedUnix ctx.Locale}}
+			{{ctx.Locale.Tr "repo.projects.note.created_by" $timeStr (.Creator.HomeLink | Escape) (.Creator.GetDisplayName | Escape) | Safe}}
 		</span>
 	</div>
 	{{if .MilestoneID}}

From 67e5a866202dc9b6dfe8c55d42ba484606b7c260 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Thu, 8 Feb 2024 22:06:23 +0100
Subject: [PATCH 37/46] fix: cursor-grap only on columns and cards if column-id
 != 0 and use can edit

---
 templates/projects/view.tmpl | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index a315f09618629..61966434cbe93 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -81,7 +81,7 @@
 <div id="project-board">
 	<div class="board {{if .CanWriteProjects}}sortable{{end}}">
 		{{range .Columns}}
-			<div class="ui segment project-column {{if $canWriteProject}}gt-cursor-grab{{end}}" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
+			<div class="ui segment project-column {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
 				<div class="project-column-header">
 					<div class="ui large label project-column-title gt-py-2">
 						<div class="ui small circular grey label project-column-issue-note-count">
@@ -240,7 +240,7 @@
 
 				<div class="cards-wrapper">
 					{{if or $canWriteProject (index $.ProjectBoardNotesMap .ID)}}
-						<div class="ui cards note-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
+						<div class="ui cards note-cards {{if $canWriteProject}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
 							{{range (index $.ProjectBoardNotesMap .ID)}}
 								<div class="note-card" data-note="{{.ID}}">
 									{{template "repo/note" (dict
@@ -256,7 +256,7 @@
 							<div class="divider"></div>
 						{{end}}
 					{{end}}
-					<div class="ui cards issue-cards {{if and $canWriteProject (ne .ID 0)}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="issue-board-{{.ID}}">
+					<div class="ui cards issue-cards {{if $canWriteProject}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="issue-board-{{.ID}}">
 						{{range (index $.IssuesMap .ID)}}
 							<div class="issue-card" data-issue="{{.ID}}">
 								{{template "repo/issue/card" (dict "Issue" . "Page" $)}}

From 1c50683c5ed90bfbef3af4dd3b7ff3d16fd00d2f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Thu, 8 Feb 2024 22:17:31 +0100
Subject: [PATCH 38/46] fix: moving pinned cards need permissions

---
 templates/projects/view.tmpl         | 2 +-
 web_src/js/features/repo-projects.js | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 61966434cbe93..72f8d1cc31c90 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -1,7 +1,7 @@
 {{$canWriteProject := and .CanWriteProjects (or (not .Repository) (not .Repository.IsArchived))}}
 
 {{if gt (len .PinnedProjectBoardNotes) 0}}
-<div id="pinned-notes">
+<div id="pinned-notes" {{if .CanWriteProjects}}class="sortable"{{end}}>
 	{{range .PinnedProjectBoardNotes}}
 		<div class="note-card pinned-card {{if $canWriteProject}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-note="{{.ID}}">
 			{{template "repo/note" (dict
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index 402549b7c9fca..5770da55d6fff 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -96,7 +96,7 @@ function movePinned({newIndex, item}) {
 }
 
 async function initRepoProjectSortable() {
-  const pinnedNotesCards = document.querySelector('#pinned-notes');
+  const pinnedNotesCards = document.querySelector('#pinned-notes.sortable');
   if (pinnedNotesCards) {
     createSortable(pinnedNotesCards, {
       group: 'pinned-shared',

From f1387ca0ad205f5e607cb74eff51a53f0e52bebe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Fri, 9 Feb 2024 00:38:10 +0100
Subject: [PATCH 39/46] enhance: show pinned note in list

---
 models/project/note.go           | 32 ++++++++++++++--
 models/project/project.go        |  3 +-
 routers/web/repo/projects.go     | 23 +++++++++++
 templates/projects/list.tmpl     | 65 +++++++++++++++++++-------------
 templates/repo/note.tmpl         | 10 ++---
 web_src/css/shared/milestone.css | 10 +++++
 6 files changed, 107 insertions(+), 36 deletions(-)

diff --git a/models/project/note.go b/models/project/note.go
index 7154e143863c0..82b853c6f2c8a 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -41,9 +41,9 @@ type ProjectBoardNoteList []*ProjectBoardNote
 
 // ProjectBoardNotesOptions represents options of an note.
 type ProjectBoardNotesOptions struct {
-	db.Paginator
 	ProjectID int64
 	BoardID   int64
+	IsPinned  util.OptionalBool
 }
 
 func init() {
@@ -51,7 +51,7 @@ func init() {
 	db.RegisterModel(new(ProjectBoardNoteLabel))
 }
 
-// GetProjectBoardNoteByID load notes assigned to the boards
+// GetProjectBoardNoteByID load note by ID
 func GetProjectBoardNoteByID(ctx context.Context, projectBoardNoteID int64) (*ProjectBoardNote, error) {
 	projectBoardNote := new(ProjectBoardNote)
 
@@ -80,6 +80,20 @@ func GetProjectBoardNotesByIds(ctx context.Context, projectBoardNoteIDs []int64)
 	return projectBoardNoteList, nil
 }
 
+// GetProjectBoardNotesByProjectID load pinned notes assigned to the project
+func GetProjectBoardNotesByProjectID(ctx context.Context, projectID int64, isPinned bool) (ProjectBoardNoteList, error) {
+	projectBoardNoteList, err := ProjectBoardNotes(ctx, &ProjectBoardNotesOptions{
+		ProjectID: projectID,
+		BoardID:   -1,
+		IsPinned:  util.OptionalBoolOf(isPinned),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return projectBoardNoteList, nil
+}
+
 // LoadProjectBoardNotesFromBoardList load notes assigned to the boards
 func (p *Project) LoadProjectBoardNotesFromBoardList(ctx context.Context, boardList BoardList) (map[int64]ProjectBoardNoteList, error) {
 	projectBoardNoteListMap := make(map[int64]ProjectBoardNoteList, len(boardList))
@@ -110,7 +124,19 @@ func LoadProjectBoardNotesFromBoard(ctx context.Context, board *Board) (ProjectB
 func ProjectBoardNotes(ctx context.Context, opts *ProjectBoardNotesOptions) (ProjectBoardNoteList, error) {
 	sess := db.GetEngine(ctx)
 
-	sess.Where(builder.Eq{"board_id": opts.BoardID}).And(builder.Eq{"project_id": opts.ProjectID})
+	if opts.BoardID >= 0 {
+		sess.Where(builder.Eq{"board_id": opts.BoardID})
+	}
+	if opts.ProjectID >= 0 {
+		sess.Where(builder.Eq{"project_id": opts.ProjectID})
+	}
+	if !opts.IsPinned.IsNone() {
+		if opts.IsPinned.IsTrue() {
+			sess.Where(builder.NotNull{"pin_order"}).And(builder.Gt{"pin_order": 0})
+		} else {
+			sess.Where(builder.IsNull{"pin_order"}).Or(builder.Eq{"pin_order": 0})
+		}
+	}
 
 	projectBoardNoteList := ProjectBoardNoteList{}
 	if err := sess.Asc("sorting").Desc("updated_unix").Desc("id").Find(&projectBoardNoteList); err != nil {
diff --git a/models/project/project.go b/models/project/project.go
index 38621f449472a..d613e3c297e78 100644
--- a/models/project/project.go
+++ b/models/project/project.go
@@ -138,7 +138,8 @@ type Project struct {
 	CardType    CardType
 	Type        Type
 
-	RenderedContent string `xorm:"-"`
+	RenderedContent             string            `xorm:"-"`
+	FirstPinnedProjectBoardNote *ProjectBoardNote `xorm:"-"`
 
 	CreatedUnix    timeutil.TimeStamp `xorm:"INDEX created"`
 	UpdatedUnix    timeutil.TimeStamp `xorm:"INDEX updated"`
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 7b54383d1b970..e196aca9769ca 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -102,6 +102,29 @@ func Projects(ctx *context.Context) {
 			ctx.ServerError("RenderString", err)
 			return
 		}
+
+		pinnedProjectBoardNotes, err := project_model.GetProjectBoardNotesByProjectID(ctx, projects[i].ID, true)
+		if err != nil {
+			ctx.ServerError("GetProjectBoardNotesByProjectID", err)
+			return
+		}
+		if len(pinnedProjectBoardNotes) > 0 {
+			firstPinnedProjectBoardNote := pinnedProjectBoardNotes[0]
+
+			firstPinnedProjectBoardNote.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
+				Links: markup.Links{
+					Base: ctx.Repo.RepoLink,
+				},
+				Metas:   ctx.Repo.Repository.ComposeMetas(ctx),
+				GitRepo: ctx.Repo.GitRepo,
+				Ctx:     ctx,
+			}, firstPinnedProjectBoardNote.Content)
+			if err != nil {
+				ctx.ServerError("RenderString", err)
+				return
+			}
+			projects[i].FirstPinnedProjectBoardNote = firstPinnedProjectBoardNote
+		}
 	}
 
 	ctx.Data["Projects"] = projects
diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl
index cbff82dd702f3..e694c76116921 100644
--- a/templates/projects/list.tmpl
+++ b/templates/projects/list.tmpl
@@ -45,39 +45,50 @@
 
 <div class="milestone-list">
 	{{range .Projects}}
-		<li class="milestone-card">
-			<h3 class="flex-text-block gt-m-0">
-				{{svg .IconName 16}}
-				<a class="muted" href="{{.Link ctx}}">{{.Title}}</a>
-			</h3>
-			<div class="milestone-toolbar">
-				<div class="group">
-					<div class="flex-text-block">
-						{{svg "octicon-issue-opened" 14}}
-						{{ctx.Locale.PrettyNumber (.NumOpenIssues ctx)}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
+		<li class="milestone-card {{if .FirstPinnedProjectBoardNote}}with-pinned-note{{end}}">
+			{{if .FirstPinnedProjectBoardNote}}
+				<div class="note-card" data-note="{{.ID}}">
+					{{template "repo/note" (dict
+						"BoardNote" .FirstPinnedProjectBoardNote
+						"root" $
+						"CanWriteProjects" false)
+					}}
+				</div>
+			{{end}}
+			<div>
+				<h3 class="flex-text-block gt-m-0">
+					{{svg .IconName 16}}
+					<a class="muted" href="{{.Link ctx}}">{{.Title}}</a>
+				</h3>
+				<div class="milestone-toolbar">
+					<div class="group">
+						<div class="flex-text-block">
+							{{svg "octicon-issue-opened" 14}}
+							{{ctx.Locale.PrettyNumber (.NumOpenIssues ctx)}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
+						</div>
+						<div class="flex-text-block">
+							{{svg "octicon-check" 14}}
+							{{ctx.Locale.PrettyNumber (.NumClosedIssues ctx)}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
+						</div>
 					</div>
-					<div class="flex-text-block">
-						{{svg "octicon-check" 14}}
-						{{ctx.Locale.PrettyNumber (.NumClosedIssues ctx)}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
+					{{if and $.CanWriteProjects (not $.Repository.IsArchived)}}
+					<div class="group">
+						<a class="flex-text-inline" href="{{.Link ctx}}/edit">{{svg "octicon-pencil" 14}}{{ctx.Locale.Tr "repo.issues.label_edit"}}</a>
+						{{if .IsClosed}}
+							<a class="link-action flex-text-inline" href data-url="{{.Link ctx}}/open">{{svg "octicon-check" 14}}{{ctx.Locale.Tr "repo.projects.open"}}</a>
+						{{else}}
+							<a class="link-action flex-text-inline" href data-url="{{.Link ctx}}/close">{{svg "octicon-skip" 14}}{{ctx.Locale.Tr "repo.projects.close"}}</a>
+						{{end}}
+						<a class="delete-button flex-text-inline" href="#" data-url="{{.Link ctx}}/delete">{{svg "octicon-trash" 14}}{{ctx.Locale.Tr "repo.issues.label_delete"}}</a>
 					</div>
-				</div>
-				{{if and $.CanWriteProjects (not $.Repository.IsArchived)}}
-				<div class="group">
-					<a class="flex-text-inline" href="{{.Link ctx}}/edit">{{svg "octicon-pencil" 14}}{{ctx.Locale.Tr "repo.issues.label_edit"}}</a>
-					{{if .IsClosed}}
-						<a class="link-action flex-text-inline" href data-url="{{.Link ctx}}/open">{{svg "octicon-check" 14}}{{ctx.Locale.Tr "repo.projects.open"}}</a>
-					{{else}}
-						<a class="link-action flex-text-inline" href data-url="{{.Link ctx}}/close">{{svg "octicon-skip" 14}}{{ctx.Locale.Tr "repo.projects.close"}}</a>
 					{{end}}
-					<a class="delete-button flex-text-inline" href="#" data-url="{{.Link ctx}}/delete">{{svg "octicon-trash" 14}}{{ctx.Locale.Tr "repo.issues.label_delete"}}</a>
+				</div>
+				{{if .Description}}
+				<div class="content">
+					{{.RenderedContent|Str2html}}
 				</div>
 				{{end}}
 			</div>
-			{{if .Description}}
-			<div class="content">
-				{{.RenderedContent|Str2html}}
-			</div>
-			{{end}}
 		</li>
 	{{end}}
 
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 31a97f1d445e7..11870cbab7851 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -20,7 +20,7 @@
 						{{if eq $.root.Context.Doer.ID .CreatorID}}
 							<button class="ui item show-modal button"
 								data-modal="#edit-project-column-note-modal-{{.ID}}"
-								data-modal-form.action="{{$.root.Link}}/{{.BoardID}}/note/{{.ID}}">
+								data-modal-form.action="{{$.root.RepoLink}}/projects/{{.ProjectID}}/{{.BoardID}}/note/{{.ID}}">
 									{{svg "octicon-pencil"}}
 									{{ctx.Locale.Tr "repo.projects.note.edit"}}
 							</button>
@@ -33,7 +33,7 @@
 						<button class="ui item button {{if $notPinnedAndNotAllowedToPin}}disabled{{end}} pin-project-column-note"
 							{{if $notPinnedAndNotAllowedToPin}}style="pointer-events: unset !important"{{end}}
 							data-method="{{if .IsPinned}}delete{{else}}post{{end}}"
-							data-url="{{$.root.Link}}/{{.BoardID}}/note/{{.ID}}/pin"
+							data-url="{{$.root.RepoLink}}/projects/{{.ProjectID}}/{{.BoardID}}/note/{{.ID}}/pin"
 							{{if $notPinnedAndNotAllowedToPin}}data-tooltip-content="{{ctx.Locale.Tr "repo.projects.note.max_pinned"}}"{{end}}>
 								{{if .IsPinned}}
 									{{svg "octicon-pin-slash"}}
@@ -46,7 +46,7 @@
 						{{if eq $.root.Context.Doer.ID .CreatorID}}
 							<button class="ui item show-modal button"
 								data-modal="#delete-project-column-note-modal-{{.ID}}"
-								data-modal-form.action="{{$.root.Link}}/{{.BoardID}}/note/{{.ID}}">
+								data-modal-form.action="{{$.root.RepoLink}}/projects/{{.ProjectID}}/{{.BoardID}}/note/{{.ID}}">
 									{{svg "octicon-trash"}}
 									{{ctx.Locale.Tr "repo.projects.note.delete"}}
 							</button>
@@ -66,7 +66,7 @@
 							<div class="field">
 								<label class="gt-font-16">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
 								{{if .LabelIDs}}
-									{{RenderLabelsFromIDs ctx .LabelIDs $.root.Link}}
+									{{RenderLabelsFromIDs ctx .LabelIDs $.root.RepoLink}}
 								{{else}}
 									{{ctx.Locale.Tr "repo.projects.note.no_label"}}
 								{{end}}
@@ -173,7 +173,7 @@
 		</div>
 	{{end}}
 	{{if .LabelIDs}}
-		{{RenderLabelsFromIDs ctx .LabelIDs $.root.Link}}
+		{{RenderLabelsFromIDs ctx .LabelIDs $.root.RepoLink}}
 	{{end}}
 </div>
 {{end}}
diff --git a/web_src/css/shared/milestone.css b/web_src/css/shared/milestone.css
index 91e6b5e387c4f..ee7d3ab31cf22 100644
--- a/web_src/css/shared/milestone.css
+++ b/web_src/css/shared/milestone.css
@@ -2,12 +2,22 @@
   list-style: none;
 }
 
+.milestone-list .note-card {
+  height: fit-content;
+}
+
 .milestone-card {
   width: 100%;
   padding-top: 10px;
   padding-bottom: 10px;
 }
 
+.milestone-card.with-pinned-note {
+  gap: 1rem;
+  display: grid;
+  grid-template-columns: minmax(30%, min-content) 1fr;
+}
+
 .milestone-card + .milestone-card {
   border-top: 1px solid var(--color-secondary);
 }

From 0aa0c5cbac67e91ab6bd6ffdc7840189c8432bca Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Fri, 9 Feb 2024 00:51:21 +0100
Subject: [PATCH 40/46] fix: show milestone in view-modal of note

---
 templates/repo/note.tmpl | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 11870cbab7851..8b73fa29c5730 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -72,6 +72,15 @@
 								{{end}}
 							</div>
 
+							<div class="field">
+								<label class="gt-font-16">{{ctx.Locale.Tr "repo.projects.note.milestone"}}</label>
+								{{if .MilestoneID}}
+									{{RenderMilestone ctx .MilestoneID $.root.Repository.ID}}
+								{{else}}
+									{{ctx.Locale.Tr "repo.projects.note.no_milestone"}}
+								{{end}}
+							</div>
+
 							<div class="field">
 								<label class="gt-font-16">{{ctx.Locale.Tr "repo.projects.note.description"}}</label>
 								{{Str2html .RenderedContent}}

From 50e8545c9024cb0ddcde6b448e8b60a218e4c70c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Fri, 9 Feb 2024 02:55:00 +0100
Subject: [PATCH 41/46] enhance: notes are now available in orgs

---
 models/project/note.go       |  15 +-
 routers/web/org/projects.go  | 311 +++++++++++++++++++++++++++++++++++
 routers/web/repo/projects.go |  48 +-----
 routers/web/web.go           |  16 ++
 templates/projects/view.tmpl |  40 +++--
 templates/repo/note.tmpl     | 108 ++++++------
 6 files changed, 418 insertions(+), 120 deletions(-)

diff --git a/models/project/note.go b/models/project/note.go
index 82b853c6f2c8a..01ec0c03633e5 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -19,14 +19,13 @@ import (
 
 // ProjectBoardNote is used to represent a note on a boards
 type ProjectBoardNote struct {
-	ID              int64   `xorm:"pk autoincr"`
-	Title           string  `xorm:"TEXT NOT NULL"`
-	Content         string  `xorm:"LONGTEXT"`
-	RenderedContent string  `xorm:"-"`
-	Sorting         int64   `xorm:"NOT NULL DEFAULT 0"`
-	PinOrder        int64   `xorm:"NOT NULL DEFAULT 0"`
-	LabelIDs        []int64 `xorm:"-"` // can't be []*Label because of 'import cycle not allowed'
-	MilestoneID     int64   `xorm:"INDEX"`
+	ID          int64   `xorm:"pk autoincr"`
+	Title       string  `xorm:"TEXT NOT NULL"`
+	Content     string  `xorm:"LONGTEXT"`
+	Sorting     int64   `xorm:"NOT NULL DEFAULT 0"`
+	PinOrder    int64   `xorm:"NOT NULL DEFAULT 0"`
+	LabelIDs    []int64 `xorm:"-"` // can't be []*Label because of 'import cycle not allowed'
+	MilestoneID int64   `xorm:"INDEX"`
 
 	ProjectID int64            `xorm:"INDEX NOT NULL"`
 	BoardID   int64            `xorm:"INDEX NOT NULL"`
diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go
index 03798a712c2ee..02f9a4c958cea 100644
--- a/routers/web/org/projects.go
+++ b/routers/web/org/projects.go
@@ -105,6 +105,15 @@ func Projects(ctx *context.Context) {
 
 	for _, project := range projects {
 		project.RenderedContent = project.Description
+
+		pinnedProjectBoardNotes, err := project_model.GetProjectBoardNotesByProjectID(ctx, project.ID, true)
+		if err != nil {
+			ctx.ServerError("GetProjectBoardNotesByProjectID", err)
+			return
+		}
+		if len(pinnedProjectBoardNotes) > 0 {
+			project.FirstPinnedProjectBoardNote = pinnedProjectBoardNotes[0]
+		}
 	}
 
 	err = shared_user.LoadHeaderCount(ctx)
@@ -362,6 +371,12 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
+	notesMap, err := project.LoadProjectBoardNotesFromBoardList(ctx, boards)
+	if err != nil {
+		ctx.ServerError("LoadProjectBoardNotesOfBoards", err)
+		return
+	}
+
 	if project.CardType != project_model.CardTypeTextOnly {
 		issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment)
 		for _, issuesList := range issuesMap {
@@ -395,6 +410,12 @@ func ViewProject(ctx *context.Context) {
 		}
 	}
 
+	pinnedBoardNotes, err := project_model.GetPinnedProjectBoardNotes(ctx, project.ID)
+	if err != nil {
+		ctx.ServerError("GetPinnedProjectBoardNotes", err)
+		return
+	}
+
 	project.RenderedContent = project.Description
 	ctx.Data["LinkedPRs"] = linkedPrsMap
 	ctx.Data["PageIsViewProjects"] = true
@@ -402,6 +423,8 @@ func ViewProject(ctx *context.Context) {
 	ctx.Data["Project"] = project
 	ctx.Data["IssuesMap"] = issuesMap
 	ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend
+	ctx.Data["PinnedProjectBoardNotes"] = pinnedBoardNotes
+	ctx.Data["ProjectBoardNotesMap"] = notesMap
 	shared_user.RenderUserHeader(ctx)
 
 	err = shared_user.LoadHeaderCount(ctx)
@@ -749,3 +772,291 @@ func MoveIssues(ctx *context.Context) {
 
 	ctx.JSONOK()
 }
+
+func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.ProjectBoardNote) {
+	if ctx.Doer == nil {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only signed in users are allowed to perform this action.",
+		})
+		return nil, nil
+	}
+
+	project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("ProjectNotFound", err)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return nil, nil
+	}
+	if project.OwnerID != ctx.ContextUser.ID {
+		ctx.NotFound("InvalidRepoID", nil)
+		return nil, nil
+	}
+
+	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	if err != nil {
+		if project_model.IsErrProjectBoardNoteNotExist(err) {
+			ctx.NotFound("ProjectBoardNoteNotFound", err)
+		} else {
+			ctx.ServerError("GetProjectBoardNoteById", err)
+		}
+		return nil, nil
+	}
+
+	if !ctx.Doer.IsAdmin && ctx.Doer.ID != projectBoardNote.CreatorID {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only the creator or an admin can perform this action.",
+		})
+		return nil, nil
+	}
+
+	if projectBoardNote.ProjectID != project.ID {
+		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
+			"message": fmt.Sprintf("ProjectBoardNote[%d] is not in Project[%d] as expected", projectBoardNote.ID, project.ID),
+		})
+		return nil, nil
+	}
+
+	return project, projectBoardNote
+}
+
+func AddProjectBoardNoteToBoard(ctx *context.Context) {
+	if ctx.Doer == nil {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only signed in users are allowed to perform this action.",
+		})
+		return
+	}
+
+	form := web.GetForm(ctx).(*forms.ProjectBoardNoteForm)
+
+	// LabelIDs is send without parentheses - maybe because of multipart/form-data
+	labelIdsString := "[" + ctx.Req.FormValue("labelIds") + "]"
+	var labelIDs []int64
+	if err := json.Unmarshal([]byte(labelIdsString), &labelIDs); err != nil {
+		ctx.ServerError("Unmarshal", err)
+	}
+
+	// check that all LabelsIDs are valid
+	for _, labelID := range labelIDs {
+		_, err := issues_model.GetLabelByID(ctx, labelID)
+		if err != nil {
+			if issues_model.IsErrLabelNotExist(err) {
+				ctx.Error(http.StatusNotFound, "GetLabelByID")
+			} else {
+				ctx.ServerError("GetLabelByID", err)
+			}
+			return
+		}
+	}
+
+	projectBoardNote := project_model.ProjectBoardNote{
+		Title:   form.Title,
+		Content: form.Content,
+
+		ProjectID: ctx.ParamsInt64(":id"),
+		BoardID:   ctx.ParamsInt64(":boardID"),
+		CreatorID: ctx.Doer.ID,
+	}
+	err := project_model.NewProjectBoardNote(ctx, &projectBoardNote)
+	if err != nil {
+		ctx.ServerError("NewProjectBoardNote", err)
+		return
+	}
+
+	if len(labelIDs) > 0 {
+		for _, labelID := range labelIDs {
+			err := projectBoardNote.AddLabel(ctx, labelID)
+			if err != nil {
+				ctx.ServerError("AddLabel", err)
+				return
+			}
+		}
+	}
+
+	ctx.JSONOK()
+}
+
+func EditProjectBoardNote(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.ProjectBoardNoteForm)
+	_, projectBoardNote := checkProjectBoardNoteChangePermissions(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	projectBoardNote.Title = form.Title
+	projectBoardNote.Content = form.Content
+
+	if err := project_model.UpdateProjectBoardNote(ctx, projectBoardNote); err != nil {
+		ctx.ServerError("UpdateProjectBoardNote", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
+
+func DeleteProjectBoardNote(ctx *context.Context) {
+	_, projectBoardNote := checkProjectBoardNoteChangePermissions(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	if err := project_model.DeleteProjectBoardNote(ctx, projectBoardNote); err != nil {
+		ctx.ServerError("DeleteProjectBoardNote", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
+
+func MoveProjectBoardNote(ctx *context.Context) {
+	if ctx.Doer == nil {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only signed in users are allowed to perform this action.",
+		})
+		return
+	}
+
+	project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("ProjectNotFound", err)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return
+	}
+
+	var board *project_model.Board
+
+	if ctx.ParamsInt64(":boardID") == 0 {
+		board = &project_model.Board{
+			ID:        0,
+			ProjectID: project.ID,
+			Title:     ctx.Tr("repo.projects.type.uncategorized"),
+		}
+	} else {
+		board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID"))
+		if err != nil {
+			if project_model.IsErrProjectBoardNotExist(err) {
+				ctx.NotFound("ProjectBoardNotExist", nil)
+			} else {
+				ctx.ServerError("GetProjectBoard", err)
+			}
+			return
+		}
+		if board.ProjectID != project.ID {
+			ctx.NotFound("BoardNotInProject", nil)
+			return
+		}
+	}
+
+	type MovedProjectBoardNotesForm struct {
+		ProjectBoardNotes []struct {
+			ProjectBoardNoteID int64 `json:"projectBoardNoteID"`
+			Sorting            int64 `json:"sorting"`
+		} `json:"projectBoardNotes"`
+	}
+
+	form := &MovedProjectBoardNotesForm{}
+	if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
+		ctx.ServerError("DecodeMovedProjectBoardNotesForm", err)
+		return
+	}
+
+	projectBoardNoteIDs := make([]int64, 0, len(form.ProjectBoardNotes))
+	sortedProjectBoardNoteIDs := make(map[int64]int64)
+	for _, boardNote := range form.ProjectBoardNotes {
+		projectBoardNoteIDs = append(projectBoardNoteIDs, boardNote.ProjectBoardNoteID)
+		sortedProjectBoardNoteIDs[boardNote.Sorting] = boardNote.ProjectBoardNoteID
+	}
+	movedProjectBoardNotes, err := project_model.GetProjectBoardNotesByIds(ctx, projectBoardNoteIDs)
+	if err != nil {
+		if project_model.IsErrProjectBoardNoteNotExist(err) {
+			ctx.NotFound("ProjectBoardNoteNotExisting", nil)
+		} else {
+			ctx.ServerError("GetProjectBoardNoteByIds", err)
+		}
+		return
+	}
+
+	if len(movedProjectBoardNotes) != len(form.ProjectBoardNotes) {
+		ctx.ServerError("some project-board-notes do not exist", errors.New("some project-board-notes do not exist"))
+		return
+	}
+
+	if err = project_model.MoveProjectBoardNoteOnProjectBoard(ctx, board, sortedProjectBoardNoteIDs); err != nil {
+		ctx.ServerError("MoveProjectBoardNoteOnProjectBoard", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
+
+// PinProjectBoardNote pins the BoardNote
+func PinProjectBoardNote(ctx *context.Context) {
+	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	if err != nil {
+		ctx.ServerError("GetProjectBoardNoteByID", err)
+		return
+	}
+
+	err = projectBoardNote.Pin(ctx)
+	if err != nil {
+		ctx.ServerError("PinProjectBoardNote", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
+
+// PinBoardNote unpins the BoardNote
+func UnPinProjectBoardNote(ctx *context.Context) {
+	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	if err != nil {
+		ctx.ServerError("GetBoardNoteByID", err)
+		return
+	}
+
+	err = projectBoardNote.Unpin(ctx)
+	if err != nil {
+		ctx.ServerError("UnpinProjectBoardNote", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
+
+// PinBoardNote moves a pined the BoardNote
+func PinMoveProjectBoardNote(ctx *context.Context) {
+	if ctx.Doer == nil {
+		ctx.JSON(http.StatusForbidden, "Only signed in users are allowed to perform this action.")
+		return
+	}
+
+	type MovePinProjectBoardNoteForm struct {
+		Position int64 `json:"position"`
+	}
+
+	form := &MovePinProjectBoardNoteForm{}
+	if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
+		ctx.ServerError("Decode MovePinProjectBoardNoteForm", err)
+		return
+	}
+
+	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	if err != nil {
+		ctx.ServerError("GetProjectBoardNoteByID", err)
+		return
+	}
+
+	err = projectBoardNote.MovePin(ctx, form.Position)
+	if err != nil {
+		ctx.ServerError("MovePinProjectBoardNote", err)
+		return
+	}
+
+	ctx.JSONOK()
+}
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index e196aca9769ca..25e8dc93251bc 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -109,21 +109,7 @@ func Projects(ctx *context.Context) {
 			return
 		}
 		if len(pinnedProjectBoardNotes) > 0 {
-			firstPinnedProjectBoardNote := pinnedProjectBoardNotes[0]
-
-			firstPinnedProjectBoardNote.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
-				Links: markup.Links{
-					Base: ctx.Repo.RepoLink,
-				},
-				Metas:   ctx.Repo.Repository.ComposeMetas(ctx),
-				GitRepo: ctx.Repo.GitRepo,
-				Ctx:     ctx,
-			}, firstPinnedProjectBoardNote.Content)
-			if err != nil {
-				ctx.ServerError("RenderString", err)
-				return
-			}
-			projects[i].FirstPinnedProjectBoardNote = firstPinnedProjectBoardNote
+			projects[i].FirstPinnedProjectBoardNote = pinnedProjectBoardNotes[0]
 		}
 	}
 
@@ -354,23 +340,6 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	for _, noteList := range notesMap {
-		for _, note := range noteList {
-			note.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
-				Links: markup.Links{
-					Base: ctx.Repo.RepoLink,
-				},
-				Metas:   ctx.Repo.Repository.ComposeMetas(ctx),
-				GitRepo: ctx.Repo.GitRepo,
-				Ctx:     ctx,
-			}, note.Content)
-			if err != nil {
-				ctx.ServerError("RenderString", err)
-				return
-			}
-		}
-	}
-
 	if project.CardType != project_model.CardTypeTextOnly {
 		issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment)
 		for _, issuesList := range issuesMap {
@@ -424,21 +393,6 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	for _, note := range pinnedBoardNotes {
-		note.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
-			Links: markup.Links{
-				Base: ctx.Repo.RepoLink,
-			},
-			Metas:   ctx.Repo.Repository.ComposeMetas(ctx),
-			GitRepo: ctx.Repo.GitRepo,
-			Ctx:     ctx,
-		}, note.Content)
-		if err != nil {
-			ctx.ServerError("RenderString", err)
-			return
-		}
-	}
-
 	labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{})
 	if err != nil {
 		ctx.ServerError("GetLabelsByRepoID", err)
diff --git a/routers/web/web.go b/routers/web/web.go
index f16bab10069cf..59dbd597971d5 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1003,6 +1003,22 @@ func registerRoutes(m *web.Route) {
 						m.Post("/unsetdefault", org.UnsetDefaultProjectBoard)
 
 						m.Post("/move", org.MoveIssues)
+
+						m.Group("/note", func() {
+							m.Post("", web.Bind(forms.ProjectBoardNoteForm{}), org.AddProjectBoardNoteToBoard)
+							m.Post("/move", org.MoveProjectBoardNote)
+
+							m.Group("/{noteID}", func() {
+								m.Put("", web.Bind(forms.ProjectBoardNoteForm{}), org.EditProjectBoardNote)
+								m.Delete("", org.DeleteProjectBoardNote)
+
+								m.Group("/pin", func() {
+									m.Post("", web.Bind(forms.ProjectBoardNoteForm{}), org.PinProjectBoardNote)
+									m.Delete("", org.UnPinProjectBoardNote)
+									m.Post("/move", org.PinMoveProjectBoardNote)
+								})
+							})
+						})
 					})
 				})
 			}, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.Context) {
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 72f8d1cc31c90..0e29fa2d82da8 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -145,27 +145,31 @@
 												<input id="new-project-column-note-title-{{.ID}}" name="title" maxlength="255" autofocus required>
 											</div>
 
-											<div class="field">
-												<label for="new-project-column-note-label-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
-												<div class="ui fluid selection dropdown multiple clearable">
-													<input type="hidden" id="new-project-column-note-label-{{.ID}}" name="labelIds">
-													{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-													<div class="default text"></div>
-													<div class="menu">
-														{{range $.Labels}}
-															<div class="item vertical gt-gap-y-2" data-value="{{.ID}}">
-																<small class="desc">{{.Description | RenderEmoji ctx}}</small>
-																<span>{{RenderLabel ctx .}}</span>
-															</div>
-														{{end}}
+											{{if and $.Labels (gt (len $.Labels) 0)}}
+												<div class="field">
+													<label for="new-project-column-note-label-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
+													<div class="ui fluid selection dropdown multiple clearable">
+														<input type="hidden" id="new-project-column-note-label-{{.ID}}" name="labelIds">
+														{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+														<div class="default text"></div>
+														<div class="menu">
+															{{range $.Labels}}
+																<div class="item vertical gt-gap-y-2" data-value="{{.ID}}">
+																	<small class="desc">{{.Description | RenderEmoji ctx}}</small>
+																	<span>{{RenderLabel ctx .}}</span>
+																</div>
+															{{end}}
+														</div>
 													</div>
 												</div>
-											</div>
+											{{end}}
 
-											<div class="field">
-												<label for="new-project-column-note-milestone-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.milestone"}}</label>
-												{{template "projects/milestone_selection" $}}
-											</div>
+											{{if or $.OpenMilestones $.ClosedMilestones}}
+												<div class="field">
+													<label for="new-project-column-note-milestone-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.milestone"}}</label>
+													{{template "projects/milestone_selection" $}}
+												</div>
+											{{end}}
 
 											<div class="field">
 												{{template "shared/combomarkdowneditor" (dict
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 8b73fa29c5730..4c73273e0c524 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -1,11 +1,15 @@
 {{with .BoardNote}}
+{{$backendURL := (print $.root.FeedURL "/projects/" .ProjectID "/" .BoardID "/note/")}}
+{{if not $.root.Repository}}
+	{{$backendURL = (print $.root.FeedURL "/-/projects/" .ProjectID "/" .BoardID "/note/")}}
+{{end}}
 <div class="content gt-p-0 gt-w-full">
 	<div class="gt-df gt-items-start">
 		<div class="project-column-note-card-icon">
 			{{svg "octicon-note"}}
 		</div>
 		<span class="note-card-title muted project-column-note-title">{{.Title}}</span>
-		{{if or $.CanWriteProjects (gt (len .RenderedContent) 0)}}
+		{{if or $.CanWriteProjects (gt (len .Content) 0)}}
 			<div class="ui dropdown jump item">
 				<div class="gt-px-3">
 					{{svg "octicon-kebab-horizontal"}}
@@ -20,20 +24,22 @@
 						{{if eq $.root.Context.Doer.ID .CreatorID}}
 							<button class="ui item show-modal button"
 								data-modal="#edit-project-column-note-modal-{{.ID}}"
-								data-modal-form.action="{{$.root.RepoLink}}/projects/{{.ProjectID}}/{{.BoardID}}/note/{{.ID}}">
+								data-modal-form.action="{{$backendURL}}{{.ID}}">
 									{{svg "octicon-pencil"}}
 									{{ctx.Locale.Tr "repo.projects.note.edit"}}
 							</button>
 						{{end}}
-						<a class="ui item button create-project-issue-from-note" href="{{$.root.RepoLink}}/issues/new/choose?project={{$.root.Project.ID}}">
-							{{svg "octicon-issue-opened"}}
-							{{ctx.Locale.Tr "repo.issues.new"}}
-						</a>
+						{{if $.root.Repository}}
+							<a class="ui item button create-project-issue-from-note" href="{{$.root.FeedURL}}/issues/new/choose?project={{$.root.Project.ID}}">
+								{{svg "octicon-issue-opened"}}
+								{{ctx.Locale.Tr "repo.issues.new"}}
+							</a>
+						{{end}}
 						{{$notPinnedAndNotAllowedToPin := and (not .IsPinned) (not (.IsNewPinAllowed ctx)) (or $.root.Context.Doer.IsAdmin (eq $.root.Context.Doer.ID .CreatorID))}}
 						<button class="ui item button {{if $notPinnedAndNotAllowedToPin}}disabled{{end}} pin-project-column-note"
 							{{if $notPinnedAndNotAllowedToPin}}style="pointer-events: unset !important"{{end}}
 							data-method="{{if .IsPinned}}delete{{else}}post{{end}}"
-							data-url="{{$.root.RepoLink}}/projects/{{.ProjectID}}/{{.BoardID}}/note/{{.ID}}/pin"
+							data-url="{{$backendURL}}{{.ID}}/pin"
 							{{if $notPinnedAndNotAllowedToPin}}data-tooltip-content="{{ctx.Locale.Tr "repo.projects.note.max_pinned"}}"{{end}}>
 								{{if .IsPinned}}
 									{{svg "octicon-pin-slash"}}
@@ -46,7 +52,7 @@
 						{{if eq $.root.Context.Doer.ID .CreatorID}}
 							<button class="ui item show-modal button"
 								data-modal="#delete-project-column-note-modal-{{.ID}}"
-								data-modal-form.action="{{$.root.RepoLink}}/projects/{{.ProjectID}}/{{.BoardID}}/note/{{.ID}}">
+								data-modal-form.action="{{$backendURL}}{{.ID}}">
 									{{svg "octicon-trash"}}
 									{{ctx.Locale.Tr "repo.projects.note.delete"}}
 							</button>
@@ -63,27 +69,31 @@
 								{{.Title}}
 							</div>
 
-							<div class="field">
-								<label class="gt-font-16">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
-								{{if .LabelIDs}}
-									{{RenderLabelsFromIDs ctx .LabelIDs $.root.RepoLink}}
-								{{else}}
-									{{ctx.Locale.Tr "repo.projects.note.no_label"}}
-								{{end}}
-							</div>
+							{{if and $.root.Labels (gt (len $.root.Labels) 0)}}
+								<div class="field">
+									<label class="gt-font-16">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
+									{{if .LabelIDs}}
+										{{RenderLabelsFromIDs ctx .LabelIDs $.root.FeedURL}}
+									{{else}}
+										{{ctx.Locale.Tr "repo.projects.note.no_label"}}
+									{{end}}
+								</div>
+							{{end}}
 
-							<div class="field">
-								<label class="gt-font-16">{{ctx.Locale.Tr "repo.projects.note.milestone"}}</label>
-								{{if .MilestoneID}}
-									{{RenderMilestone ctx .MilestoneID $.root.Repository.ID}}
-								{{else}}
-									{{ctx.Locale.Tr "repo.projects.note.no_milestone"}}
-								{{end}}
-							</div>
+							{{if or $.root.OpenMilestones $.root.ClosedMilestones}}
+								<div class="field">
+									<label class="gt-font-16">{{ctx.Locale.Tr "repo.projects.note.milestone"}}</label>
+									{{if .MilestoneID}}
+										{{RenderMilestone ctx .MilestoneID $.root.Repository.ID}}
+									{{else}}
+										{{ctx.Locale.Tr "repo.projects.note.no_milestone"}}
+									{{end}}
+								</div>
+							{{end}}
 
 							<div class="field">
 								<label class="gt-font-16">{{ctx.Locale.Tr "repo.projects.note.description"}}</label>
-								{{Str2html .RenderedContent}}
+								{{RenderMarkdownToHtml ctx .Content}}
 							</div>
 						</div>
 						<div class="actions">
@@ -104,32 +114,36 @@
 										<input id="edit-project-column-note-title-{{.ID}}" name="title" value="{{.Title}}" maxlength="255" autofocus required>
 									</div>
 
-									<div class="field">
-										<label for="edit-project-column-note-label-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
-										<div class="ui fluid selection dropdown multiple clearable">
-											<input type="hidden" id="edit-project-column-note-label-{{.ID}}" name="labelIds" value="{{range $index, $val := .LabelIDs}}{{if ne $index 0}},{{end}}{{$val}}{{end}}">
-											{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-											<div class="default text"></div>
-											<div class="menu">
-												{{range $.root.Labels}}
-													<div class="item vertical gt-gap-y-2" data-value="{{.ID}}">
-														<small class="desc">{{.Description | RenderEmoji ctx}}</small>
-														<span>{{RenderLabel ctx .}}</span>
-													</div>
-												{{end}}
+									{{if and $.root.Labels (gt (len $.root.Labels) 0)}}
+										<div class="field">
+											<label for="edit-project-column-note-label-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
+											<div class="ui fluid selection dropdown multiple clearable">
+												<input type="hidden" id="edit-project-column-note-label-{{.ID}}" name="labelIds" value="{{range $index, $val := .LabelIDs}}{{if ne $index 0}},{{end}}{{$val}}{{end}}">
+												{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+												<div class="default text"></div>
+												<div class="menu">
+													{{range $.root.Labels}}
+														<div class="item vertical gt-gap-y-2" data-value="{{.ID}}">
+															<small class="desc">{{.Description | RenderEmoji ctx}}</small>
+															<span>{{RenderLabel ctx .}}</span>
+														</div>
+													{{end}}
+												</div>
 											</div>
 										</div>
-									</div>
+									{{end}}
 
-									<div class="field">
-										<label for="edit-project-column-note-milestone-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.milestone"}}</label>
-										{{template "projects/milestone_selection" (dict "." $.root "MilestoneID" .MilestoneID)}}
-									</div>
+									{{if or $.root.OpenMilestones $.root.ClosedMilestones}}
+										<div class="field">
+											<label for="edit-project-column-note-milestone-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.milestone"}}</label>
+											{{template "projects/milestone_selection" (dict "." $.root "MilestoneID" .MilestoneID)}}
+										</div>
+									{{end}}
 
 									<div class="field">
 										{{template "shared/combomarkdowneditor" (dict
-											"MarkdownPreviewUrl" (print $.root.RepoLink "/markup")
-											"MarkdownPreviewContext" $.root.RepoLink
+											"MarkdownPreviewUrl" (print $.root.FeedURL "/markup")
+											"MarkdownPreviewContext" $.root.FeedURL
 											"TextareaName" "content"
 											"TextareaContent" .Content
 											"TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.note.description")
@@ -161,7 +175,7 @@
 		{{end}}
 	</div>
 	<div class="project-column-note-content">
-		{{Str2html .RenderedContent}}
+		{{RenderMarkdownToHtml ctx .Content}}
 	</div>
 	<div class="meta">
 		<span class="text light grey muted-links">
@@ -182,7 +196,7 @@
 		</div>
 	{{end}}
 	{{if .LabelIDs}}
-		{{RenderLabelsFromIDs ctx .LabelIDs $.root.RepoLink}}
+		{{RenderLabelsFromIDs ctx .LabelIDs $.root.FeedURL}}
 	{{end}}
 </div>
 {{end}}

From 2f4631acf5649120c208302e9fc4d52f23dd94e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Fri, 9 Feb 2024 03:31:17 +0100
Subject: [PATCH 42/46] enhance: milestone and label selectors with search

---
 templates/projects/milestone_selection.tmpl | 2 +-
 templates/projects/view.tmpl                | 2 +-
 templates/repo/note.tmpl                    | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/templates/projects/milestone_selection.tmpl b/templates/projects/milestone_selection.tmpl
index be9314244e3bf..85b06226d32a4 100644
--- a/templates/projects/milestone_selection.tmpl
+++ b/templates/projects/milestone_selection.tmpl
@@ -1,4 +1,4 @@
-<div class="ui fluid selection dropdown clearable">
+<div class="ui fluid selection search normal dropdown clearable">
 	<input type="hidden" id="edit-project-column-note-milestone-{{.ID}}" name="milestoneId" {{if and .MilestoneID (gt .MilestoneID 0)}}value="{{.MilestoneID}}"{{end}}>
 	{{svg "octicon-triangle-down" 14 "dropdown icon"}}
 	{{svg "octicon-x" 14 "remove icon"}}
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 0e29fa2d82da8..d77559cc0e3a3 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -148,7 +148,7 @@
 											{{if and $.Labels (gt (len $.Labels) 0)}}
 												<div class="field">
 													<label for="new-project-column-note-label-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
-													<div class="ui fluid selection dropdown multiple clearable">
+													<div class="ui fluid selection search normal dropdown multiple clearable">
 														<input type="hidden" id="new-project-column-note-label-{{.ID}}" name="labelIds">
 														{{svg "octicon-triangle-down" 14 "dropdown icon"}}
 														<div class="default text"></div>
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 4c73273e0c524..1c183a1f21b1d 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -117,7 +117,7 @@
 									{{if and $.root.Labels (gt (len $.root.Labels) 0)}}
 										<div class="field">
 											<label for="edit-project-column-note-label-{{.ID}}">{{ctx.Locale.Tr "repo.projects.note.labels"}}</label>
-											<div class="ui fluid selection dropdown multiple clearable">
+											<div class="ui fluid selection search normal dropdown multiple clearable">
 												<input type="hidden" id="edit-project-column-note-label-{{.ID}}" name="labelIds" value="{{range $index, $val := .LabelIDs}}{{if ne $index 0}},{{end}}{{$val}}{{end}}">
 												{{svg "octicon-triangle-down" 14 "dropdown icon"}}
 												<div class="default text"></div>

From 6222c42dd9e80802b25bb39aecfdafce9b959484 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Fri, 9 Feb 2024 03:32:30 +0100
Subject: [PATCH 43/46] chore: add migration

---
 models/migrations/v1_22/v287.go | 41 +++++++++++++++++++++++++++++++++
 1 file changed, 41 insertions(+)
 create mode 100644 models/migrations/v1_22/v287.go

diff --git a/models/migrations/v1_22/v287.go b/models/migrations/v1_22/v287.go
new file mode 100644
index 0000000000000..53f58025aaa49
--- /dev/null
+++ b/models/migrations/v1_22/v287.go
@@ -0,0 +1,41 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_22 //nolint
+
+import (
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/xorm"
+)
+
+func CreateTablesForProjectBoardNotes(x *xorm.Engine) error {
+	type ProjectBoardNote struct {
+		ID          int64  `xorm:"pk autoincr"`
+		Title       string `xorm:"TEXT NOT NULL"`
+		Content     string `xorm:"LONGTEXT"`
+		Sorting     int64  `xorm:"NOT NULL DEFAULT 0"`
+		PinOrder    int64  `xorm:"NOT NULL DEFAULT 0"`
+		MilestoneID int64  `xorm:"INDEX"`
+
+		ProjectID int64 `xorm:"INDEX NOT NULL"`
+		BoardID   int64 `xorm:"INDEX NOT NULL"`
+		CreatorID int64 `xorm:"NOT NULL"`
+
+		CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+		UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+	}
+
+	type ProjectBoardNoteLabel struct {
+		ID                 int64 `xorm:"pk autoincr"`
+		ProjectBoardNoteID int64 `xorm:"UNIQUE(s) NOT NULL"`
+		LabelID            int64 `xorm:"UNIQUE(s) NOT NULL"`
+	}
+
+	err := x.Sync(new(ProjectBoardNote))
+	if err != nil {
+		return err
+	}
+
+	return x.Sync(new(ProjectBoardNoteLabel))
+}

From 98cb5c036a501eac9a2aa9f0c090d593d99d791e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Fri, 9 Feb 2024 03:41:54 +0100
Subject: [PATCH 44/46] i18n: translations for milestone

---
 options/locale/locale_en-US.ini             | 5 +++++
 templates/projects/milestone_selection.tmpl | 6 +++---
 2 files changed, 8 insertions(+), 3 deletions(-)

diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 073d7a1337e18..201da4f7d875c 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1357,6 +1357,11 @@ projects.note.new = New Note
 projects.note.title = Title
 projects.note.labels = Labels
 projects.note.no_label = No Label
+projects.note.milestone = Milestone
+projects.note.no_milestone = No Milestone
+projects.note.no_milestones = No Milestones
+projects.note.open_milestones = Open Milestones
+projects.note.closed_milestones = Closed Milestones
 projects.note.description = Description
 projects.note.edit = Edit Note
 projects.note.delete = Delete Note
diff --git a/templates/projects/milestone_selection.tmpl b/templates/projects/milestone_selection.tmpl
index 85b06226d32a4..86639e3ab1e17 100644
--- a/templates/projects/milestone_selection.tmpl
+++ b/templates/projects/milestone_selection.tmpl
@@ -6,13 +6,13 @@
 	<div class="menu">
 		{{if and (not .OpenMilestones) (not .ClosedMilestones)}}
 			<div class="disabled item">
-				{{ctx.Locale.Tr "repo.projects.note.no_items"}}
+				{{ctx.Locale.Tr "repo.projects.note.no_milestones"}}
 			</div>
 		{{else}}
 			{{if .OpenMilestones}}
 				<div class="divider"></div>
 				<div class="header">
-					{{ctx.Locale.Tr "repo.projects.note.open_milestone"}}
+					{{ctx.Locale.Tr "repo.projects.note.open_milestones"}}
 				</div>
 				{{range .OpenMilestones}}
 					<div class="item" data-value="{{.ID}}">
@@ -23,7 +23,7 @@
 			{{if .ClosedMilestones}}
 				{{if gt (len .OpenMilestones) 0}}<div class="divider"></div>{{end}}
 				<div class="header">
-					{{ctx.Locale.Tr "repo.projects.note.closed_milestone"}}
+					{{ctx.Locale.Tr "repo.projects.note.closed_milestones"}}
 				</div>
 				{{range .ClosedMilestones}}
 					<div class="item" data-value="{{.ID}}">

From 0f74de61dd8b5178fcd4d2b929827fd622324997 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Fri, 9 Feb 2024 04:28:54 +0100
Subject: [PATCH 45/46] chore: fmt and lint

---
 models/migrations/v1_22/v287.go             |  56 +++++----
 models/project/board.go                     |  14 +--
 models/project/note.go                      | 109 ++++++++--------
 models/project/note_label.go                |  47 +++----
 models/project/project.go                   |  42 +++----
 modules/templates/util_render.go            |   2 +-
 routers/web/org/projects.go                 | 128 +++++++++----------
 routers/web/repo/projects.go                | 130 ++++++++++----------
 routers/web/web.go                          |  32 ++---
 services/forms/repo_form.go                 |   4 +-
 templates/projects/list.tmpl                |   6 +-
 templates/projects/milestone_selection.tmpl |   2 +-
 templates/projects/view.tmpl                |  14 +--
 templates/repo/note.tmpl                    |   2 +-
 14 files changed, 304 insertions(+), 284 deletions(-)

diff --git a/models/migrations/v1_22/v287.go b/models/migrations/v1_22/v287.go
index 53f58025aaa49..9fd2e693edca8 100644
--- a/models/migrations/v1_22/v287.go
+++ b/models/migrations/v1_22/v287.go
@@ -9,33 +9,43 @@ import (
 	"xorm.io/xorm"
 )
 
-func CreateTablesForProjectBoardNotes(x *xorm.Engine) error {
-	type ProjectBoardNote struct {
-		ID          int64  `xorm:"pk autoincr"`
-		Title       string `xorm:"TEXT NOT NULL"`
-		Content     string `xorm:"LONGTEXT"`
-		Sorting     int64  `xorm:"NOT NULL DEFAULT 0"`
-		PinOrder    int64  `xorm:"NOT NULL DEFAULT 0"`
-		MilestoneID int64  `xorm:"INDEX"`
-
-		ProjectID int64 `xorm:"INDEX NOT NULL"`
-		BoardID   int64 `xorm:"INDEX NOT NULL"`
-		CreatorID int64 `xorm:"NOT NULL"`
-
-		CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
-		UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
-	}
+type BoardNote struct {
+	ID          int64  `xorm:"pk autoincr"`
+	Title       string `xorm:"TEXT NOT NULL"`
+	Content     string `xorm:"LONGTEXT"`
+	Sorting     int64  `xorm:"NOT NULL DEFAULT 0"`
+	PinOrder    int64  `xorm:"NOT NULL DEFAULT 0"`
+	MilestoneID int64  `xorm:"INDEX"`
+
+	ProjectID int64 `xorm:"INDEX NOT NULL"`
+	BoardID   int64 `xorm:"INDEX NOT NULL"`
+	CreatorID int64 `xorm:"NOT NULL"`
+
+	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+}
 
-	type ProjectBoardNoteLabel struct {
-		ID                 int64 `xorm:"pk autoincr"`
-		ProjectBoardNoteID int64 `xorm:"UNIQUE(s) NOT NULL"`
-		LabelID            int64 `xorm:"UNIQUE(s) NOT NULL"`
-	}
+// TableName xorm will read the table name from this method
+func (BoardNote) TableName() string {
+	return "project_board_note"
+}
+
+type BoardNoteLabel struct {
+	ID          int64 `xorm:"pk autoincr"`
+	BoardNoteID int64 `xorm:"UNIQUE(s) NOT NULL"`
+	LabelID     int64 `xorm:"UNIQUE(s) NOT NULL"`
+}
+
+// TableName xorm will read the table name from this method
+func (BoardNoteLabel) TableName() string {
+	return "project_board_note_label"
+}
 
-	err := x.Sync(new(ProjectBoardNote))
+func CreateTablesForBoardNotes(x *xorm.Engine) error {
+	err := x.Sync(new(BoardNote))
 	if err != nil {
 		return err
 	}
 
-	return x.Sync(new(ProjectBoardNoteLabel))
+	return x.Sync(new(BoardNoteLabel))
 }
diff --git a/models/project/board.go b/models/project/board.go
index d759336cc44db..04265dbb19f93 100644
--- a/models/project/board.go
+++ b/models/project/board.go
@@ -82,8 +82,8 @@ func (b *Board) NumIssues(ctx context.Context) int {
 	return int(c)
 }
 
-// NumProjectBoardNotes return counter of all notes assigned to the board
-func (b *Board) NumProjectBoardNotes(ctx context.Context) int {
+// NumBoardNotes return counter of all notes assigned to the board
+func (b *Board) NumBoardNotes(ctx context.Context) int {
 	c, err := db.GetEngine(ctx).Table("project_board_note").
 		Where("project_id=?", b.ProjectID).
 		And("board_id=?", b.ID).
@@ -96,11 +96,11 @@ func (b *Board) NumProjectBoardNotes(ctx context.Context) int {
 	return int(c)
 }
 
-// NumIssuesAndProjectBoardNotes return counter of all issues and notes assigned to the board
-func (b *Board) NumIssuesAndProjectBoardNotes(ctx context.Context) int {
+// NumIssuesAndNotes return counter of all issues and notes assigned to the board
+func (b *Board) NumIssuesAndNotes(ctx context.Context) int {
 	numIssues := b.NumIssues(ctx)
-	numProjectBoardNotes := b.NumProjectBoardNotes(ctx)
-	return numIssues + numProjectBoardNotes
+	numBoardNotes := b.NumBoardNotes(ctx)
+	return numIssues + numBoardNotes
 }
 
 func init() {
@@ -201,7 +201,7 @@ func deleteBoardByID(ctx context.Context, boardID int64) error {
 		return err
 	}
 
-	if err = board.removeProjectBoardNotes(ctx); err != nil {
+	if err = board.removeBoardNotes(ctx); err != nil {
 		return err
 	}
 
diff --git a/models/project/note.go b/models/project/note.go
index 01ec0c03633e5..43600b4d6d01a 100644
--- a/models/project/note.go
+++ b/models/project/note.go
@@ -17,8 +17,8 @@ import (
 	"xorm.io/builder"
 )
 
-// ProjectBoardNote is used to represent a note on a boards
-type ProjectBoardNote struct {
+// BoardNote is used to represent a note on a boards
+type BoardNote struct {
 	ID          int64   `xorm:"pk autoincr"`
 	Title       string  `xorm:"TEXT NOT NULL"`
 	Content     string  `xorm:"LONGTEXT"`
@@ -36,37 +36,42 @@ type ProjectBoardNote struct {
 	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
 }
 
-type ProjectBoardNoteList []*ProjectBoardNote
+// TableName xorm will read the table name from this method
+func (*BoardNote) TableName() string {
+	return "project_board_note"
+}
+
+type BoardNoteList []*BoardNote
 
-// ProjectBoardNotesOptions represents options of an note.
-type ProjectBoardNotesOptions struct {
+// BoardNotesOptions represents options of an note.
+type BoardNotesOptions struct {
 	ProjectID int64
 	BoardID   int64
 	IsPinned  util.OptionalBool
 }
 
 func init() {
-	db.RegisterModel(new(ProjectBoardNote))
-	db.RegisterModel(new(ProjectBoardNoteLabel))
+	db.RegisterModel(new(BoardNote))
+	db.RegisterModel(new(BoardNoteLabel))
 }
 
-// GetProjectBoardNoteByID load note by ID
-func GetProjectBoardNoteByID(ctx context.Context, projectBoardNoteID int64) (*ProjectBoardNote, error) {
-	projectBoardNote := new(ProjectBoardNote)
+// GetBoardNoteByID load note by ID
+func GetBoardNoteByID(ctx context.Context, projectBoardNoteID int64) (*BoardNote, error) {
+	projectBoardNote := new(BoardNote)
 
 	has, err := db.GetEngine(ctx).ID(projectBoardNoteID).Get(projectBoardNote)
 	if err != nil {
 		return nil, err
 	} else if !has {
-		return nil, ErrProjectBoardNoteNotExist{ProjectBoardNoteID: projectBoardNoteID}
+		return nil, ErrBoardNoteNotExist{BoardNoteID: projectBoardNoteID}
 	}
 
 	return projectBoardNote, nil
 }
 
-// GetProjectBoardNotesByIds return notes with the given IDs.
-func GetProjectBoardNotesByIds(ctx context.Context, projectBoardNoteIDs []int64) (ProjectBoardNoteList, error) {
-	projectBoardNoteList := make(ProjectBoardNoteList, 0, len(projectBoardNoteIDs))
+// GetBoardNotesByIds return notes with the given IDs.
+func GetBoardNotesByIds(ctx context.Context, projectBoardNoteIDs []int64) (BoardNoteList, error) {
+	projectBoardNoteList := make(BoardNoteList, 0, len(projectBoardNoteIDs))
 
 	if err := db.GetEngine(ctx).In("id", projectBoardNoteIDs).Find(&projectBoardNoteList); err != nil {
 		return nil, err
@@ -79,9 +84,9 @@ func GetProjectBoardNotesByIds(ctx context.Context, projectBoardNoteIDs []int64)
 	return projectBoardNoteList, nil
 }
 
-// GetProjectBoardNotesByProjectID load pinned notes assigned to the project
-func GetProjectBoardNotesByProjectID(ctx context.Context, projectID int64, isPinned bool) (ProjectBoardNoteList, error) {
-	projectBoardNoteList, err := ProjectBoardNotes(ctx, &ProjectBoardNotesOptions{
+// GetBoardNotesByProjectID load pinned notes assigned to the project
+func GetBoardNotesByProjectID(ctx context.Context, projectID int64, isPinned bool) (BoardNoteList, error) {
+	projectBoardNoteList, err := BoardNotes(ctx, &BoardNotesOptions{
 		ProjectID: projectID,
 		BoardID:   -1,
 		IsPinned:  util.OptionalBoolOf(isPinned),
@@ -93,11 +98,11 @@ func GetProjectBoardNotesByProjectID(ctx context.Context, projectID int64, isPin
 	return projectBoardNoteList, nil
 }
 
-// LoadProjectBoardNotesFromBoardList load notes assigned to the boards
-func (p *Project) LoadProjectBoardNotesFromBoardList(ctx context.Context, boardList BoardList) (map[int64]ProjectBoardNoteList, error) {
-	projectBoardNoteListMap := make(map[int64]ProjectBoardNoteList, len(boardList))
+// LoadBoardNotesFromBoardList load notes assigned to the boards
+func (p *Project) LoadBoardNotesFromBoardList(ctx context.Context, boardList BoardList) (map[int64]BoardNoteList, error) {
+	projectBoardNoteListMap := make(map[int64]BoardNoteList, len(boardList))
 	for i := range boardList {
-		il, err := LoadProjectBoardNotesFromBoard(ctx, boardList[i])
+		il, err := LoadBoardNotesFromBoard(ctx, boardList[i])
 		if err != nil {
 			return nil, err
 		}
@@ -106,9 +111,9 @@ func (p *Project) LoadProjectBoardNotesFromBoardList(ctx context.Context, boardL
 	return projectBoardNoteListMap, nil
 }
 
-// LoadProjectBoardNotesFromBoard load notes assigned to this board
-func LoadProjectBoardNotesFromBoard(ctx context.Context, board *Board) (ProjectBoardNoteList, error) {
-	projectBoardNoteList, err := ProjectBoardNotes(ctx, &ProjectBoardNotesOptions{
+// LoadBoardNotesFromBoard load notes assigned to this board
+func LoadBoardNotesFromBoard(ctx context.Context, board *Board) (BoardNoteList, error) {
+	projectBoardNoteList, err := BoardNotes(ctx, &BoardNotesOptions{
 		ProjectID: board.ProjectID,
 		BoardID:   board.ID,
 	})
@@ -119,8 +124,8 @@ func LoadProjectBoardNotesFromBoard(ctx context.Context, board *Board) (ProjectB
 	return projectBoardNoteList, nil
 }
 
-// ProjectBoardNotes returns a list of notes by given conditions.
-func ProjectBoardNotes(ctx context.Context, opts *ProjectBoardNotesOptions) (ProjectBoardNoteList, error) {
+// BoardNotes returns a list of notes by given conditions.
+func BoardNotes(ctx context.Context, opts *BoardNotesOptions) (BoardNoteList, error) {
 	sess := db.GetEngine(ctx)
 
 	if opts.BoardID >= 0 {
@@ -137,7 +142,7 @@ func ProjectBoardNotes(ctx context.Context, opts *ProjectBoardNotesOptions) (Pro
 		}
 	}
 
-	projectBoardNoteList := ProjectBoardNoteList{}
+	projectBoardNoteList := BoardNoteList{}
 	if err := sess.Asc("sorting").Desc("updated_unix").Desc("id").Find(&projectBoardNoteList); err != nil {
 		return nil, fmt.Errorf("unable to query project-board-notes: %w", err)
 	}
@@ -149,14 +154,14 @@ func ProjectBoardNotes(ctx context.Context, opts *ProjectBoardNotesOptions) (Pro
 	return projectBoardNoteList, nil
 }
 
-// NewProjectBoardNote adds a new note to a given board
-func NewProjectBoardNote(ctx context.Context, projectBoardNote *ProjectBoardNote) error {
+// NewBoardNote adds a new note to a given board
+func NewBoardNote(ctx context.Context, projectBoardNote *BoardNote) error {
 	_, err := db.GetEngine(ctx).Insert(projectBoardNote)
 	return err
 }
 
 // LoadAttributes prerenders the markdown content and sets the creator
-func (projectBoardNoteList ProjectBoardNoteList) LoadAttributes(ctx context.Context) error {
+func (projectBoardNoteList BoardNoteList) LoadAttributes(ctx context.Context) error {
 	for _, projectBoardNote := range projectBoardNoteList {
 		creator := new(user_model.User)
 		has, err := db.GetEngine(ctx).ID(projectBoardNote.CreatorID).Get(creator)
@@ -179,12 +184,12 @@ func (projectBoardNoteList ProjectBoardNoteList) LoadAttributes(ctx context.Cont
 var ErrBoardNoteMaxPinReached = util.NewInvalidArgumentErrorf("the max number of pinned project-board-notes has been readched")
 
 // IsPinned returns if a BoardNote is pinned
-func (projectBoardNote *ProjectBoardNote) IsPinned() bool {
+func (projectBoardNote *BoardNote) IsPinned() bool {
 	return projectBoardNote.PinOrder != 0
 }
 
 // IsPinned returns if a BoardNote is pinned
-func (projectBoardNote *ProjectBoardNote) GetMaxPinOrder(ctx context.Context) (int64, error) {
+func (projectBoardNote *BoardNote) GetMaxPinOrder(ctx context.Context) (int64, error) {
 	var maxPin int64
 	_, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM project_board_note WHERE project_id = ?", projectBoardNote.ProjectID).Get(&maxPin)
 	if err != nil {
@@ -194,7 +199,7 @@ func (projectBoardNote *ProjectBoardNote) GetMaxPinOrder(ctx context.Context) (i
 }
 
 // IsPinned returns if a BoardNote is pinned
-func (projectBoardNote *ProjectBoardNote) IsNewPinAllowed(ctx context.Context) bool {
+func (projectBoardNote *BoardNote) IsNewPinAllowed(ctx context.Context) bool {
 	maxPin, err := projectBoardNote.GetMaxPinOrder(ctx)
 	if err != nil {
 		return false
@@ -205,7 +210,7 @@ func (projectBoardNote *ProjectBoardNote) IsNewPinAllowed(ctx context.Context) b
 }
 
 // Pin pins a BoardNote
-func (projectBoardNote *ProjectBoardNote) Pin(ctx context.Context) error {
+func (projectBoardNote *BoardNote) Pin(ctx context.Context) error {
 	// If the BoardNote is already pinned, we don't need to pin it twice
 	if projectBoardNote.IsPinned() {
 		return nil
@@ -234,7 +239,7 @@ func (projectBoardNote *ProjectBoardNote) Pin(ctx context.Context) error {
 }
 
 // Unpin unpins a BoardNote
-func (projectBoardNote *ProjectBoardNote) Unpin(ctx context.Context) error {
+func (projectBoardNote *BoardNote) Unpin(ctx context.Context) error {
 	// If the BoardNote is not pinned, we don't need to unpin it
 	if !projectBoardNote.IsPinned() {
 		return nil
@@ -259,7 +264,7 @@ func (projectBoardNote *ProjectBoardNote) Unpin(ctx context.Context) error {
 }
 
 // MovePin moves a Pinned BoardNote to a new Position
-func (projectBoardNote *ProjectBoardNote) MovePin(ctx context.Context, newPosition int64) error {
+func (projectBoardNote *BoardNote) MovePin(ctx context.Context, newPosition int64) error {
 	// If the BoardNote is not pinned, we can't move them
 	if !projectBoardNote.IsPinned() {
 		return nil
@@ -309,9 +314,9 @@ func (projectBoardNote *ProjectBoardNote) MovePin(ctx context.Context, newPositi
 	return committer.Commit()
 }
 
-// GetPinnedProjectBoardNotes returns the pinned BaordNotes for the given Project
-func GetPinnedProjectBoardNotes(ctx context.Context, projectID int64) (ProjectBoardNoteList, error) {
-	projectBoardNoteList := make(ProjectBoardNoteList, 0)
+// GetPinnedBoardNotes returns the pinned BaordNotes for the given Project
+func GetPinnedBoardNotes(ctx context.Context, projectID int64) (BoardNoteList, error) {
+	projectBoardNoteList := make(BoardNoteList, 0)
 
 	err := db.GetEngine(ctx).
 		Table("project_board_note").
@@ -331,17 +336,17 @@ func GetPinnedProjectBoardNotes(ctx context.Context, projectID int64) (ProjectBo
 }
 
 // GetTasks returns the amount of tasks in the project-board-notes content
-func (projectBoardNote *ProjectBoardNote) GetTasks() int {
+func (projectBoardNote *BoardNote) GetTasks() int {
 	return len(markdown.MarkdownTasksRegex.FindAllStringIndex(projectBoardNote.Content, -1))
 }
 
 // GetTasksDone returns the amount of completed tasks in the project-board-notes content
-func (projectBoardNote *ProjectBoardNote) GetTasksDone() int {
+func (projectBoardNote *BoardNote) GetTasksDone() int {
 	return len(markdown.MarkdownTasksDoneRegex.FindAllStringIndex(projectBoardNote.Content, -1))
 }
 
-// UpdateProjectBoardNote changes a BoardNote
-func UpdateProjectBoardNote(ctx context.Context, projectBoardNote *ProjectBoardNote) error {
+// UpdateBoardNote changes a BoardNote
+func UpdateBoardNote(ctx context.Context, projectBoardNote *BoardNote) error {
 	var fieldToUpdate []string
 
 	fieldToUpdate = append(fieldToUpdate, "title")
@@ -352,12 +357,12 @@ func UpdateProjectBoardNote(ctx context.Context, projectBoardNote *ProjectBoardN
 	return err
 }
 
-// MoveProjectBoardNoteOnProjectBoard moves or keeps notes in a column and sorts them inside that column
-func MoveProjectBoardNoteOnProjectBoard(ctx context.Context, board *Board, sortedProjectBoardNoteIDs map[int64]int64) error {
+// MoveBoardNoteOnProjectBoard moves or keeps notes in a column and sorts them inside that column
+func MoveBoardNoteOnProjectBoard(ctx context.Context, board *Board, sortedBoardNoteIDs map[int64]int64) error {
 	return db.WithTx(ctx, func(ctx context.Context) error {
 		sess := db.GetEngine(ctx)
 
-		for sorting, issueID := range sortedProjectBoardNoteIDs {
+		for sorting, issueID := range sortedBoardNoteIDs {
 			_, err := sess.Exec("UPDATE `project_board_note` SET board_id=?, sorting=? WHERE id=?", board.ID, sorting, issueID)
 			if err != nil {
 				return err
@@ -367,21 +372,21 @@ func MoveProjectBoardNoteOnProjectBoard(ctx context.Context, board *Board, sorte
 	})
 }
 
-func deleteProjectBoardNoteByProjectID(ctx context.Context, projectID int64) error {
-	_, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&ProjectBoardNote{})
+func deleteBoardNoteByProjectID(ctx context.Context, projectID int64) error {
+	_, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&BoardNote{})
 	return err
 }
 
-// DeleteProjectBoardNote removes the BoardNote from the project board.
-func DeleteProjectBoardNote(ctx context.Context, boardNote *ProjectBoardNote) error {
+// DeleteBoardNote removes the BoardNote from the project board.
+func DeleteBoardNote(ctx context.Context, boardNote *BoardNote) error {
 	if _, err := db.GetEngine(ctx).Delete(boardNote); err != nil {
 		return err
 	}
 	return nil
 }
 
-// removeProjectBoardNotes sets the boardID to 0 for the board
-func (b *Board) removeProjectBoardNotes(ctx context.Context) error {
+// removeBoardNotes sets the boardID to 0 for the board
+func (b *Board) removeBoardNotes(ctx context.Context) error {
 	_, err := db.GetEngine(ctx).Exec("UPDATE `project_board_note` SET board_id = 0 WHERE board_id = ?", b.ID)
 	return err
 }
diff --git a/models/project/note_label.go b/models/project/note_label.go
index 562d45670eaa6..48bbb61c7e60d 100644
--- a/models/project/note_label.go
+++ b/models/project/note_label.go
@@ -10,56 +10,61 @@ import (
 	"code.gitea.io/gitea/models/db"
 )
 
-// ProjectBoardNoteLabel represents an project-baord-note-label relation.
-type ProjectBoardNoteLabel struct {
-	ID                 int64 `xorm:"pk autoincr"`
-	ProjectBoardNoteID int64 `xorm:"UNIQUE(s) NOT NULL"`
-	LabelID            int64 `xorm:"UNIQUE(s) NOT NULL"`
+// BoardNoteLabel represents an project-baord-note-label relation.
+type BoardNoteLabel struct {
+	ID          int64 `xorm:"pk autoincr"`
+	BoardNoteID int64 `xorm:"UNIQUE(s) NOT NULL"`
+	LabelID     int64 `xorm:"UNIQUE(s) NOT NULL"`
+}
+
+// TableName xorm will read the table name from this method
+func (*BoardNoteLabel) TableName() string {
+	return "project_board_note_label"
 }
 
 // LoadLabels loads labels
-func (projectBoardNote *ProjectBoardNote) LoadLabelIDs(ctx context.Context) (err error) {
+func (projectBoardNote *BoardNote) LoadLabelIDs(ctx context.Context) (err error) {
 	if projectBoardNote.LabelIDs != nil || len(projectBoardNote.LabelIDs) == 0 {
-		projectBoardNote.LabelIDs, err = GetLabelsByProjectBoardNoteID(ctx, projectBoardNote.ID)
+		projectBoardNote.LabelIDs, err = GetLabelsByBoardNoteID(ctx, projectBoardNote.ID)
 		if err != nil {
-			return fmt.Errorf("GetLabelsByProjectBoardNoteID [%d]: %w", projectBoardNote.ID, err)
+			return fmt.Errorf("GetLabelsByBoardNoteID [%d]: %w", projectBoardNote.ID, err)
 		}
 	}
 	return nil
 }
 
 // LoadLabels removes all labels from project-board-note
-func (projectBoardNote *ProjectBoardNote) RemoveAllLabels(ctx context.Context) error {
-	_, err := db.GetEngine(ctx).Where("project_board_note_id = ?", projectBoardNote.ID).Delete(ProjectBoardNoteLabel{})
+func (projectBoardNote *BoardNote) RemoveAllLabels(ctx context.Context) error {
+	_, err := db.GetEngine(ctx).Where("board_note_id = ?", projectBoardNote.ID).Delete(BoardNoteLabel{})
 	return err
 }
 
 // LoadLabels add a label to project-board-note -> requires a valid labelID
-func (projectBoardNote *ProjectBoardNote) AddLabel(ctx context.Context, labelID int64) error {
-	_, err := db.GetEngine(ctx).Insert(ProjectBoardNoteLabel{
-		ProjectBoardNoteID: projectBoardNote.ID,
-		LabelID:            labelID,
+func (projectBoardNote *BoardNote) AddLabel(ctx context.Context, labelID int64) error {
+	_, err := db.GetEngine(ctx).Insert(BoardNoteLabel{
+		BoardNoteID: projectBoardNote.ID,
+		LabelID:     labelID,
 	})
 	return err
 }
 
 // LoadLabels removes a label from project-board-note
-func (projectBoardNote *ProjectBoardNote) RemoveLabelByID(ctx context.Context, labelID int64) error {
-	_, err := db.GetEngine(ctx).Delete(ProjectBoardNoteLabel{
-		ProjectBoardNoteID: projectBoardNote.ID,
-		LabelID:            labelID,
+func (projectBoardNote *BoardNote) RemoveLabelByID(ctx context.Context, labelID int64) error {
+	_, err := db.GetEngine(ctx).Delete(BoardNoteLabel{
+		BoardNoteID: projectBoardNote.ID,
+		LabelID:     labelID,
 	})
 	return err
 }
 
-// GetLabelsByProjectBoardNoteID returns all labelIDs that belong to given projectBoardNote by ID.
-func GetLabelsByProjectBoardNoteID(ctx context.Context, projectBoardNoteID int64) ([]int64, error) {
+// GetLabelsByBoardNoteID returns all labelIDs that belong to given projectBoardNote by ID.
+func GetLabelsByBoardNoteID(ctx context.Context, projectBoardNoteID int64) ([]int64, error) {
 	var labelIDs []int64
 	return labelIDs, db.GetEngine(ctx).
 		Table("label").
 		Cols("label.id").
 		Asc("label.name").
-		Where("project_board_note_label.project_board_note_id = ?", projectBoardNoteID).
+		Where("project_board_note_label.board_note_id = ?", projectBoardNoteID).
 		Join("INNER", "project_board_note_label", "project_board_note_label.label_id = label.id").
 		Find(&labelIDs)
 }
diff --git a/models/project/project.go b/models/project/project.go
index d613e3c297e78..9ceb6979ea125 100644
--- a/models/project/project.go
+++ b/models/project/project.go
@@ -85,41 +85,41 @@ func (err ErrProjectBoardNotExist) Unwrap() error {
 	return util.ErrNotExist
 }
 
-// ErrProjectBoardNoteNotExist represents a "ProjectBoardNotExist" kind of error.
-type ErrProjectBoardNoteNotExist struct {
-	ProjectBoardNoteID int64
+// ErrBoardNoteNotExist represents a "ProjectBoardNotExist" kind of error.
+type ErrBoardNoteNotExist struct {
+	BoardNoteID int64
 }
 
-// IsErrProjectBoardNoteNotExist checks if an error is a ErrProjectBoardNoteNotExist
-func IsErrProjectBoardNoteNotExist(err error) bool {
-	_, ok := err.(ErrProjectBoardNoteNotExist)
+// IsErrBoardNoteNotExist checks if an error is a ErrBoardNoteNotExist
+func IsErrBoardNoteNotExist(err error) bool {
+	_, ok := err.(ErrBoardNoteNotExist)
 	return ok
 }
 
-func (err ErrProjectBoardNoteNotExist) Error() string {
-	return fmt.Sprintf("project-board-note does not exist [id: %d]", err.ProjectBoardNoteID)
+func (err ErrBoardNoteNotExist) Error() string {
+	return fmt.Sprintf("project-board-note does not exist [id: %d]", err.BoardNoteID)
 }
 
-func (err ErrProjectBoardNoteNotExist) Unwrap() error {
+func (err ErrBoardNoteNotExist) Unwrap() error {
 	return util.ErrNotExist
 }
 
-// ErrProjectBoardNoteLabelNotExist represents a "ProjectBoardNotExist" kind of error.
-type ErrProjectBoardNoteLabelNotExist struct {
-	ProjectBoardNoteLabelID int64
+// ErrBoardNoteLabelNotExist represents a "ProjectBoardNotExist" kind of error.
+type ErrBoardNoteLabelNotExist struct {
+	BoardNoteLabelID int64
 }
 
-// IsErrProjectBoardNoteLabelNotExist checks if an error is a ErrProjectBoardNoteLabelNotExist
-func IsErrProjectBoardNoteLabelNotExist(err error) bool {
-	_, ok := err.(ErrProjectBoardNoteLabelNotExist)
+// IsErrBoardNoteLabelNotExist checks if an error is a ErrBoardNoteLabelNotExist
+func IsErrBoardNoteLabelNotExist(err error) bool {
+	_, ok := err.(ErrBoardNoteLabelNotExist)
 	return ok
 }
 
-func (err ErrProjectBoardNoteLabelNotExist) Error() string {
-	return fmt.Sprintf("project-board-note-label does not exist [id: %d]", err.ProjectBoardNoteLabelID)
+func (err ErrBoardNoteLabelNotExist) Error() string {
+	return fmt.Sprintf("project-board-note-label does not exist [id: %d]", err.BoardNoteLabelID)
 }
 
-func (err ErrProjectBoardNoteLabelNotExist) Unwrap() error {
+func (err ErrBoardNoteLabelNotExist) Unwrap() error {
 	return util.ErrNotExist
 }
 
@@ -138,8 +138,8 @@ type Project struct {
 	CardType    CardType
 	Type        Type
 
-	RenderedContent             string            `xorm:"-"`
-	FirstPinnedProjectBoardNote *ProjectBoardNote `xorm:"-"`
+	RenderedContent      string     `xorm:"-"`
+	FirstPinnedBoardNote *BoardNote `xorm:"-"`
 
 	CreatedUnix    timeutil.TimeStamp `xorm:"INDEX created"`
 	UpdatedUnix    timeutil.TimeStamp `xorm:"INDEX updated"`
@@ -450,7 +450,7 @@ func DeleteProjectByID(ctx context.Context, id int64) error {
 			return err
 		}
 
-		if err := deleteProjectBoardNoteByProjectID(ctx, id); err != nil {
+		if err := deleteBoardNoteByProjectID(ctx, id); err != nil {
 			return err
 		}
 
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
index e7208116f55f7..e680fdbdebadf 100644
--- a/modules/templates/util_render.go
+++ b/modules/templates/util_render.go
@@ -247,7 +247,7 @@ func RenderLabelsFromIDs(ctx context.Context, labelIDs []int64, repoLink string)
 	return template.HTML(htmlCode)
 }
 
-func RenderMilestone(ctx context.Context, milestoneID int64, repoID int64, classes ...string) template.HTML {
+func RenderMilestone(ctx context.Context, milestoneID, repoID int64, classes ...string) template.HTML {
 	repo, err := repo_model.GetRepositoryByID(ctx, repoID)
 	if err != nil {
 		log.Error("GetRepositoryByID", err)
diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go
index 02f9a4c958cea..341c7845ee091 100644
--- a/routers/web/org/projects.go
+++ b/routers/web/org/projects.go
@@ -106,13 +106,13 @@ func Projects(ctx *context.Context) {
 	for _, project := range projects {
 		project.RenderedContent = project.Description
 
-		pinnedProjectBoardNotes, err := project_model.GetProjectBoardNotesByProjectID(ctx, project.ID, true)
+		pinnedBoardNotes, err := project_model.GetBoardNotesByProjectID(ctx, project.ID, true)
 		if err != nil {
-			ctx.ServerError("GetProjectBoardNotesByProjectID", err)
+			ctx.ServerError("GetBoardNotesByProjectID", err)
 			return
 		}
-		if len(pinnedProjectBoardNotes) > 0 {
-			project.FirstPinnedProjectBoardNote = pinnedProjectBoardNotes[0]
+		if len(pinnedBoardNotes) > 0 {
+			project.FirstPinnedBoardNote = pinnedBoardNotes[0]
 		}
 	}
 
@@ -371,9 +371,9 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	notesMap, err := project.LoadProjectBoardNotesFromBoardList(ctx, boards)
+	notesMap, err := project.LoadBoardNotesFromBoardList(ctx, boards)
 	if err != nil {
-		ctx.ServerError("LoadProjectBoardNotesOfBoards", err)
+		ctx.ServerError("LoadBoardNotesOfBoards", err)
 		return
 	}
 
@@ -410,9 +410,9 @@ func ViewProject(ctx *context.Context) {
 		}
 	}
 
-	pinnedBoardNotes, err := project_model.GetPinnedProjectBoardNotes(ctx, project.ID)
+	pinnedBoardNotes, err := project_model.GetPinnedBoardNotes(ctx, project.ID)
 	if err != nil {
-		ctx.ServerError("GetPinnedProjectBoardNotes", err)
+		ctx.ServerError("GetPinnedBoardNotes", err)
 		return
 	}
 
@@ -423,8 +423,8 @@ func ViewProject(ctx *context.Context) {
 	ctx.Data["Project"] = project
 	ctx.Data["IssuesMap"] = issuesMap
 	ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend
-	ctx.Data["PinnedProjectBoardNotes"] = pinnedBoardNotes
-	ctx.Data["ProjectBoardNotesMap"] = notesMap
+	ctx.Data["PinnedColumnNotes"] = pinnedBoardNotes
+	ctx.Data["ColumnNotesMap"] = notesMap
 	shared_user.RenderUserHeader(ctx)
 
 	err = shared_user.LoadHeaderCount(ctx)
@@ -773,7 +773,7 @@ func MoveIssues(ctx *context.Context) {
 	ctx.JSONOK()
 }
 
-func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.ProjectBoardNote) {
+func checkBoardNoteChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.BoardNote) {
 	if ctx.Doer == nil {
 		ctx.JSON(http.StatusForbidden, map[string]string{
 			"message": "Only signed in users are allowed to perform this action.",
@@ -795,12 +795,12 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 		return nil, nil
 	}
 
-	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
-		if project_model.IsErrProjectBoardNoteNotExist(err) {
-			ctx.NotFound("ProjectBoardNoteNotFound", err)
+		if project_model.IsErrBoardNoteNotExist(err) {
+			ctx.NotFound("BoardNoteNotFound", err)
 		} else {
-			ctx.ServerError("GetProjectBoardNoteById", err)
+			ctx.ServerError("GetBoardNoteById", err)
 		}
 		return nil, nil
 	}
@@ -814,7 +814,7 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 
 	if projectBoardNote.ProjectID != project.ID {
 		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
-			"message": fmt.Sprintf("ProjectBoardNote[%d] is not in Project[%d] as expected", projectBoardNote.ID, project.ID),
+			"message": fmt.Sprintf("BoardNote[%d] is not in Project[%d] as expected", projectBoardNote.ID, project.ID),
 		})
 		return nil, nil
 	}
@@ -822,7 +822,7 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 	return project, projectBoardNote
 }
 
-func AddProjectBoardNoteToBoard(ctx *context.Context) {
+func AddBoardNoteToBoard(ctx *context.Context) {
 	if ctx.Doer == nil {
 		ctx.JSON(http.StatusForbidden, map[string]string{
 			"message": "Only signed in users are allowed to perform this action.",
@@ -830,7 +830,7 @@ func AddProjectBoardNoteToBoard(ctx *context.Context) {
 		return
 	}
 
-	form := web.GetForm(ctx).(*forms.ProjectBoardNoteForm)
+	form := web.GetForm(ctx).(*forms.BoardNoteForm)
 
 	// LabelIDs is send without parentheses - maybe because of multipart/form-data
 	labelIdsString := "[" + ctx.Req.FormValue("labelIds") + "]"
@@ -852,7 +852,7 @@ func AddProjectBoardNoteToBoard(ctx *context.Context) {
 		}
 	}
 
-	projectBoardNote := project_model.ProjectBoardNote{
+	projectBoardNote := project_model.BoardNote{
 		Title:   form.Title,
 		Content: form.Content,
 
@@ -860,9 +860,9 @@ func AddProjectBoardNoteToBoard(ctx *context.Context) {
 		BoardID:   ctx.ParamsInt64(":boardID"),
 		CreatorID: ctx.Doer.ID,
 	}
-	err := project_model.NewProjectBoardNote(ctx, &projectBoardNote)
+	err := project_model.NewBoardNote(ctx, &projectBoardNote)
 	if err != nil {
-		ctx.ServerError("NewProjectBoardNote", err)
+		ctx.ServerError("NewBoardNote", err)
 		return
 	}
 
@@ -879,9 +879,9 @@ func AddProjectBoardNoteToBoard(ctx *context.Context) {
 	ctx.JSONOK()
 }
 
-func EditProjectBoardNote(ctx *context.Context) {
-	form := web.GetForm(ctx).(*forms.ProjectBoardNoteForm)
-	_, projectBoardNote := checkProjectBoardNoteChangePermissions(ctx)
+func EditBoardNote(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.BoardNoteForm)
+	_, projectBoardNote := checkBoardNoteChangePermissions(ctx)
 	if ctx.Written() {
 		return
 	}
@@ -889,29 +889,29 @@ func EditProjectBoardNote(ctx *context.Context) {
 	projectBoardNote.Title = form.Title
 	projectBoardNote.Content = form.Content
 
-	if err := project_model.UpdateProjectBoardNote(ctx, projectBoardNote); err != nil {
-		ctx.ServerError("UpdateProjectBoardNote", err)
+	if err := project_model.UpdateBoardNote(ctx, projectBoardNote); err != nil {
+		ctx.ServerError("UpdateBoardNote", err)
 		return
 	}
 
 	ctx.JSONOK()
 }
 
-func DeleteProjectBoardNote(ctx *context.Context) {
-	_, projectBoardNote := checkProjectBoardNoteChangePermissions(ctx)
+func DeleteBoardNote(ctx *context.Context) {
+	_, projectBoardNote := checkBoardNoteChangePermissions(ctx)
 	if ctx.Written() {
 		return
 	}
 
-	if err := project_model.DeleteProjectBoardNote(ctx, projectBoardNote); err != nil {
-		ctx.ServerError("DeleteProjectBoardNote", err)
+	if err := project_model.DeleteBoardNote(ctx, projectBoardNote); err != nil {
+		ctx.ServerError("DeleteBoardNote", err)
 		return
 	}
 
 	ctx.JSONOK()
 }
 
-func MoveProjectBoardNote(ctx *context.Context) {
+func MoveBoardNote(ctx *context.Context) {
 	if ctx.Doer == nil {
 		ctx.JSON(http.StatusForbidden, map[string]string{
 			"message": "Only signed in users are allowed to perform this action.",
@@ -953,59 +953,59 @@ func MoveProjectBoardNote(ctx *context.Context) {
 		}
 	}
 
-	type MovedProjectBoardNotesForm struct {
-		ProjectBoardNotes []struct {
-			ProjectBoardNoteID int64 `json:"projectBoardNoteID"`
-			Sorting            int64 `json:"sorting"`
+	type MovedBoardNotesForm struct {
+		BoardNotes []struct {
+			BoardNoteID int64 `json:"projectBoardNoteID"`
+			Sorting     int64 `json:"sorting"`
 		} `json:"projectBoardNotes"`
 	}
 
-	form := &MovedProjectBoardNotesForm{}
+	form := &MovedBoardNotesForm{}
 	if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
-		ctx.ServerError("DecodeMovedProjectBoardNotesForm", err)
+		ctx.ServerError("DecodeMovedBoardNotesForm", err)
 		return
 	}
 
-	projectBoardNoteIDs := make([]int64, 0, len(form.ProjectBoardNotes))
-	sortedProjectBoardNoteIDs := make(map[int64]int64)
-	for _, boardNote := range form.ProjectBoardNotes {
-		projectBoardNoteIDs = append(projectBoardNoteIDs, boardNote.ProjectBoardNoteID)
-		sortedProjectBoardNoteIDs[boardNote.Sorting] = boardNote.ProjectBoardNoteID
+	projectBoardNoteIDs := make([]int64, 0, len(form.BoardNotes))
+	sortedBoardNoteIDs := make(map[int64]int64)
+	for _, boardNote := range form.BoardNotes {
+		projectBoardNoteIDs = append(projectBoardNoteIDs, boardNote.BoardNoteID)
+		sortedBoardNoteIDs[boardNote.Sorting] = boardNote.BoardNoteID
 	}
-	movedProjectBoardNotes, err := project_model.GetProjectBoardNotesByIds(ctx, projectBoardNoteIDs)
+	movedBoardNotes, err := project_model.GetBoardNotesByIds(ctx, projectBoardNoteIDs)
 	if err != nil {
-		if project_model.IsErrProjectBoardNoteNotExist(err) {
-			ctx.NotFound("ProjectBoardNoteNotExisting", nil)
+		if project_model.IsErrBoardNoteNotExist(err) {
+			ctx.NotFound("BoardNoteNotExisting", nil)
 		} else {
-			ctx.ServerError("GetProjectBoardNoteByIds", err)
+			ctx.ServerError("GetBoardNoteByIds", err)
 		}
 		return
 	}
 
-	if len(movedProjectBoardNotes) != len(form.ProjectBoardNotes) {
+	if len(movedBoardNotes) != len(form.BoardNotes) {
 		ctx.ServerError("some project-board-notes do not exist", errors.New("some project-board-notes do not exist"))
 		return
 	}
 
-	if err = project_model.MoveProjectBoardNoteOnProjectBoard(ctx, board, sortedProjectBoardNoteIDs); err != nil {
-		ctx.ServerError("MoveProjectBoardNoteOnProjectBoard", err)
+	if err = project_model.MoveBoardNoteOnProjectBoard(ctx, board, sortedBoardNoteIDs); err != nil {
+		ctx.ServerError("MoveBoardNoteOnProjectBoard", err)
 		return
 	}
 
 	ctx.JSONOK()
 }
 
-// PinProjectBoardNote pins the BoardNote
-func PinProjectBoardNote(ctx *context.Context) {
-	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+// PinBoardNote pins the BoardNote
+func PinBoardNote(ctx *context.Context) {
+	projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
-		ctx.ServerError("GetProjectBoardNoteByID", err)
+		ctx.ServerError("GetBoardNoteByID", err)
 		return
 	}
 
 	err = projectBoardNote.Pin(ctx)
 	if err != nil {
-		ctx.ServerError("PinProjectBoardNote", err)
+		ctx.ServerError("PinBoardNote", err)
 		return
 	}
 
@@ -1013,8 +1013,8 @@ func PinProjectBoardNote(ctx *context.Context) {
 }
 
 // PinBoardNote unpins the BoardNote
-func UnPinProjectBoardNote(ctx *context.Context) {
-	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+func UnPinBoardNote(ctx *context.Context) {
+	projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
 		ctx.ServerError("GetBoardNoteByID", err)
 		return
@@ -1022,7 +1022,7 @@ func UnPinProjectBoardNote(ctx *context.Context) {
 
 	err = projectBoardNote.Unpin(ctx)
 	if err != nil {
-		ctx.ServerError("UnpinProjectBoardNote", err)
+		ctx.ServerError("UnpinBoardNote", err)
 		return
 	}
 
@@ -1030,31 +1030,31 @@ func UnPinProjectBoardNote(ctx *context.Context) {
 }
 
 // PinBoardNote moves a pined the BoardNote
-func PinMoveProjectBoardNote(ctx *context.Context) {
+func PinMoveBoardNote(ctx *context.Context) {
 	if ctx.Doer == nil {
 		ctx.JSON(http.StatusForbidden, "Only signed in users are allowed to perform this action.")
 		return
 	}
 
-	type MovePinProjectBoardNoteForm struct {
+	type MovePinBoardNoteForm struct {
 		Position int64 `json:"position"`
 	}
 
-	form := &MovePinProjectBoardNoteForm{}
+	form := &MovePinBoardNoteForm{}
 	if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
-		ctx.ServerError("Decode MovePinProjectBoardNoteForm", err)
+		ctx.ServerError("Decode MovePinBoardNoteForm", err)
 		return
 	}
 
-	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
-		ctx.ServerError("GetProjectBoardNoteByID", err)
+		ctx.ServerError("GetBoardNoteByID", err)
 		return
 	}
 
 	err = projectBoardNote.MovePin(ctx, form.Position)
 	if err != nil {
-		ctx.ServerError("MovePinProjectBoardNote", err)
+		ctx.ServerError("MovePinBoardNote", err)
 		return
 	}
 
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 25e8dc93251bc..0f8638c20bca5 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -103,13 +103,13 @@ func Projects(ctx *context.Context) {
 			return
 		}
 
-		pinnedProjectBoardNotes, err := project_model.GetProjectBoardNotesByProjectID(ctx, projects[i].ID, true)
+		pinnedBoardNotes, err := project_model.GetBoardNotesByProjectID(ctx, projects[i].ID, true)
 		if err != nil {
-			ctx.ServerError("GetProjectBoardNotesByProjectID", err)
+			ctx.ServerError("GetBoardNotesByProjectID", err)
 			return
 		}
-		if len(pinnedProjectBoardNotes) > 0 {
-			projects[i].FirstPinnedProjectBoardNote = pinnedProjectBoardNotes[0]
+		if len(pinnedBoardNotes) > 0 {
+			projects[i].FirstPinnedBoardNote = pinnedBoardNotes[0]
 		}
 	}
 
@@ -334,9 +334,9 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	notesMap, err := project.LoadProjectBoardNotesFromBoardList(ctx, boards)
+	notesMap, err := project.LoadBoardNotesFromBoardList(ctx, boards)
 	if err != nil {
-		ctx.ServerError("LoadProjectBoardNotesOfBoards", err)
+		ctx.ServerError("LoadBoardNotesOfBoards", err)
 		return
 	}
 
@@ -387,9 +387,9 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	pinnedBoardNotes, err := project_model.GetPinnedProjectBoardNotes(ctx, project.ID)
+	pinnedBoardNotes, err := project_model.GetPinnedBoardNotes(ctx, project.ID)
 	if err != nil {
-		ctx.ServerError("GetPinnedProjectBoardNotes", err)
+		ctx.ServerError("GetPinnedBoardNotes", err)
 		return
 	}
 
@@ -424,8 +424,8 @@ func ViewProject(ctx *context.Context) {
 	ctx.Data["Project"] = project
 	ctx.Data["IssuesMap"] = issuesMap
 	ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend
-	ctx.Data["PinnedProjectBoardNotes"] = pinnedBoardNotes
-	ctx.Data["ProjectBoardNotesMap"] = notesMap
+	ctx.Data["PinnedColumnNotes"] = pinnedBoardNotes
+	ctx.Data["ColumnNotesMap"] = notesMap
 
 	ctx.HTML(http.StatusOK, tplProjectsView)
 }
@@ -750,7 +750,7 @@ func MoveIssues(ctx *context.Context) {
 	ctx.JSONOK()
 }
 
-func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.ProjectBoardNote) {
+func checkBoardNoteChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.BoardNote) {
 	if ctx.Doer == nil {
 		ctx.JSON(http.StatusForbidden, map[string]string{
 			"message": "Only signed in users are allowed to perform this action.",
@@ -775,12 +775,12 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 		return nil, nil
 	}
 
-	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
-		if project_model.IsErrProjectBoardNoteNotExist(err) {
-			ctx.NotFound("ProjectBoardNoteNotFound", err)
+		if project_model.IsErrBoardNoteNotExist(err) {
+			ctx.NotFound("BoardNoteNotFound", err)
 		} else {
-			ctx.ServerError("GetProjectBoardNoteById", err)
+			ctx.ServerError("GetBoardNoteById", err)
 		}
 		return nil, nil
 	}
@@ -794,7 +794,7 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 
 	if projectBoardNote.ProjectID != project.ID {
 		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
-			"message": fmt.Sprintf("ProjectBoardNote[%d] is not in Project[%d] as expected", projectBoardNote.ID, project.ID),
+			"message": fmt.Sprintf("BoardNote[%d] is not in Project[%d] as expected", projectBoardNote.ID, project.ID),
 		})
 		return nil, nil
 	}
@@ -809,8 +809,8 @@ func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_mode
 	return project, projectBoardNote
 }
 
-func AddProjectBoardNoteToBoard(ctx *context.Context) {
-	form := web.GetForm(ctx).(*forms.ProjectBoardNoteForm)
+func AddBoardNoteToBoard(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.BoardNoteForm)
 	if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) {
 		ctx.JSON(http.StatusForbidden, map[string]string{
 			"message": "Only authorized users are allowed to perform this action.",
@@ -838,7 +838,7 @@ func AddProjectBoardNoteToBoard(ctx *context.Context) {
 		}
 	}
 
-	projectBoardNote := project_model.ProjectBoardNote{
+	projectBoardNote := project_model.BoardNote{
 		Title:       form.Title,
 		Content:     form.Content,
 		MilestoneID: form.MilestoneID,
@@ -847,9 +847,9 @@ func AddProjectBoardNoteToBoard(ctx *context.Context) {
 		BoardID:   ctx.ParamsInt64(":boardID"),
 		CreatorID: ctx.Doer.ID,
 	}
-	err := project_model.NewProjectBoardNote(ctx, &projectBoardNote)
+	err := project_model.NewBoardNote(ctx, &projectBoardNote)
 	if err != nil {
-		ctx.ServerError("NewProjectBoardNote", err)
+		ctx.ServerError("NewBoardNote", err)
 		return
 	}
 
@@ -866,7 +866,7 @@ func AddProjectBoardNoteToBoard(ctx *context.Context) {
 	ctx.JSONOK()
 }
 
-func findNewAndRemovedIDs(original []int64, updated []int64) (newValues, removedValues []int64) {
+func findNewAndRemovedIDs(original, updated []int64) (newValues, removedValues []int64) {
 	if original == nil {
 		return updated, nil
 	}
@@ -891,9 +891,9 @@ func findNewAndRemovedIDs(original []int64, updated []int64) (newValues, removed
 	return newValues, removedValues
 }
 
-func EditProjectBoardNote(ctx *context.Context) {
-	form := web.GetForm(ctx).(*forms.ProjectBoardNoteForm)
-	_, projectBoardNote := checkProjectBoardNoteChangePermissions(ctx)
+func EditBoardNote(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.BoardNoteForm)
+	_, projectBoardNote := checkBoardNoteChangePermissions(ctx)
 	if ctx.Written() {
 		return
 	}
@@ -907,8 +907,8 @@ func EditProjectBoardNote(ctx *context.Context) {
 		ctx.ServerError("LoadLabelIDs", err)
 	}
 
-	if err := project_model.UpdateProjectBoardNote(ctx, projectBoardNote); err != nil {
-		ctx.ServerError("UpdateProjectBoardNote", err)
+	if err := project_model.UpdateBoardNote(ctx, projectBoardNote); err != nil {
+		ctx.ServerError("UpdateBoardNote", err)
 		return
 	}
 
@@ -960,21 +960,21 @@ func EditProjectBoardNote(ctx *context.Context) {
 	ctx.JSONOK()
 }
 
-func DeleteProjectBoardNote(ctx *context.Context) {
-	_, projectBoardNote := checkProjectBoardNoteChangePermissions(ctx)
+func DeleteBoardNote(ctx *context.Context) {
+	_, projectBoardNote := checkBoardNoteChangePermissions(ctx)
 	if ctx.Written() {
 		return
 	}
 
-	if err := project_model.DeleteProjectBoardNote(ctx, projectBoardNote); err != nil {
-		ctx.ServerError("DeleteProjectBoardNote", err)
+	if err := project_model.DeleteBoardNote(ctx, projectBoardNote); err != nil {
+		ctx.ServerError("DeleteBoardNote", err)
 		return
 	}
 
 	ctx.JSONOK()
 }
 
-func MoveProjectBoardNote(ctx *context.Context) {
+func MoveBoardNote(ctx *context.Context) {
 	if ctx.Doer == nil {
 		ctx.JSON(http.StatusForbidden, map[string]string{
 			"message": "Only signed in users are allowed to perform this action.",
@@ -1023,59 +1023,59 @@ func MoveProjectBoardNote(ctx *context.Context) {
 		}
 	}
 
-	type MovedProjectBoardNotesForm struct {
-		ProjectBoardNotes []struct {
-			ProjectBoardNoteID int64 `json:"projectBoardNoteID"`
-			Sorting            int64 `json:"sorting"`
+	type MovedBoardNotesForm struct {
+		BoardNotes []struct {
+			BoardNoteID int64 `json:"projectBoardNoteID"`
+			Sorting     int64 `json:"sorting"`
 		} `json:"projectBoardNotes"`
 	}
 
-	form := &MovedProjectBoardNotesForm{}
+	form := &MovedBoardNotesForm{}
 	if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
-		ctx.ServerError("DecodeMovedProjectBoardNotesForm", err)
+		ctx.ServerError("DecodeMovedBoardNotesForm", err)
 		return
 	}
 
-	projectBoardNoteIDs := make([]int64, 0, len(form.ProjectBoardNotes))
-	sortedProjectBoardNoteIDs := make(map[int64]int64)
-	for _, boardNote := range form.ProjectBoardNotes {
-		projectBoardNoteIDs = append(projectBoardNoteIDs, boardNote.ProjectBoardNoteID)
-		sortedProjectBoardNoteIDs[boardNote.Sorting] = boardNote.ProjectBoardNoteID
+	projectBoardNoteIDs := make([]int64, 0, len(form.BoardNotes))
+	sortedBoardNoteIDs := make(map[int64]int64)
+	for _, boardNote := range form.BoardNotes {
+		projectBoardNoteIDs = append(projectBoardNoteIDs, boardNote.BoardNoteID)
+		sortedBoardNoteIDs[boardNote.Sorting] = boardNote.BoardNoteID
 	}
-	movedProjectBoardNotes, err := project_model.GetProjectBoardNotesByIds(ctx, projectBoardNoteIDs)
+	movedBoardNotes, err := project_model.GetBoardNotesByIds(ctx, projectBoardNoteIDs)
 	if err != nil {
-		if project_model.IsErrProjectBoardNoteNotExist(err) {
-			ctx.NotFound("ProjectBoardNoteNotExisting", nil)
+		if project_model.IsErrBoardNoteNotExist(err) {
+			ctx.NotFound("BoardNoteNotExisting", nil)
 		} else {
-			ctx.ServerError("GetProjectBoardNoteByIds", err)
+			ctx.ServerError("GetBoardNoteByIds", err)
 		}
 		return
 	}
 
-	if len(movedProjectBoardNotes) != len(form.ProjectBoardNotes) {
+	if len(movedBoardNotes) != len(form.BoardNotes) {
 		ctx.ServerError("some project-board-notes do not exist", errors.New("some project-board-notes do not exist"))
 		return
 	}
 
-	if err = project_model.MoveProjectBoardNoteOnProjectBoard(ctx, board, sortedProjectBoardNoteIDs); err != nil {
-		ctx.ServerError("MoveProjectBoardNoteOnProjectBoard", err)
+	if err = project_model.MoveBoardNoteOnProjectBoard(ctx, board, sortedBoardNoteIDs); err != nil {
+		ctx.ServerError("MoveBoardNoteOnProjectBoard", err)
 		return
 	}
 
 	ctx.JSONOK()
 }
 
-// PinProjectBoardNote pins the BoardNote
-func PinProjectBoardNote(ctx *context.Context) {
-	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+// PinBoardNote pins the BoardNote
+func PinBoardNote(ctx *context.Context) {
+	projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
-		ctx.ServerError("GetProjectBoardNoteByID", err)
+		ctx.ServerError("GetBoardNoteByID", err)
 		return
 	}
 
 	err = projectBoardNote.Pin(ctx)
 	if err != nil {
-		ctx.ServerError("PinProjectBoardNote", err)
+		ctx.ServerError("PinBoardNote", err)
 		return
 	}
 
@@ -1083,8 +1083,8 @@ func PinProjectBoardNote(ctx *context.Context) {
 }
 
 // PinBoardNote unpins the BoardNote
-func UnPinProjectBoardNote(ctx *context.Context) {
-	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+func UnPinBoardNote(ctx *context.Context) {
+	projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
 		ctx.ServerError("GetBoardNoteByID", err)
 		return
@@ -1092,7 +1092,7 @@ func UnPinProjectBoardNote(ctx *context.Context) {
 
 	err = projectBoardNote.Unpin(ctx)
 	if err != nil {
-		ctx.ServerError("UnpinProjectBoardNote", err)
+		ctx.ServerError("UnpinBoardNote", err)
 		return
 	}
 
@@ -1100,31 +1100,31 @@ func UnPinProjectBoardNote(ctx *context.Context) {
 }
 
 // PinBoardNote moves a pined the BoardNote
-func PinMoveProjectBoardNote(ctx *context.Context) {
+func PinMoveBoardNote(ctx *context.Context) {
 	if ctx.Doer == nil {
 		ctx.JSON(http.StatusForbidden, "Only signed in users are allowed to perform this action.")
 		return
 	}
 
-	type MovePinProjectBoardNoteForm struct {
+	type MovePinBoardNoteForm struct {
 		Position int64 `json:"position"`
 	}
 
-	form := &MovePinProjectBoardNoteForm{}
+	form := &MovePinBoardNoteForm{}
 	if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
-		ctx.ServerError("Decode MovePinProjectBoardNoteForm", err)
+		ctx.ServerError("Decode MovePinBoardNoteForm", err)
 		return
 	}
 
-	projectBoardNote, err := project_model.GetProjectBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
+	projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID"))
 	if err != nil {
-		ctx.ServerError("GetProjectBoardNoteByID", err)
+		ctx.ServerError("GetBoardNoteByID", err)
 		return
 	}
 
 	err = projectBoardNote.MovePin(ctx, form.Position)
 	if err != nil {
-		ctx.ServerError("MovePinProjectBoardNote", err)
+		ctx.ServerError("MovePinBoardNote", err)
 		return
 	}
 
diff --git a/routers/web/web.go b/routers/web/web.go
index 59dbd597971d5..ba4b8f40c6502 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -985,7 +985,7 @@ func registerRoutes(m *web.Route) {
 				m.Get("", org.Projects)
 				m.Get("/{id}", org.ViewProject)
 			}, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true))
-			m.Group("", func() {
+			m.Group("", func() { //nolint:dupl
 				m.Get("/new", org.RenderNewProject)
 				m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
 				m.Group("/{id}", func() {
@@ -1005,17 +1005,17 @@ func registerRoutes(m *web.Route) {
 						m.Post("/move", org.MoveIssues)
 
 						m.Group("/note", func() {
-							m.Post("", web.Bind(forms.ProjectBoardNoteForm{}), org.AddProjectBoardNoteToBoard)
-							m.Post("/move", org.MoveProjectBoardNote)
+							m.Post("", web.Bind(forms.BoardNoteForm{}), org.AddBoardNoteToBoard)
+							m.Post("/move", org.MoveBoardNote)
 
 							m.Group("/{noteID}", func() {
-								m.Put("", web.Bind(forms.ProjectBoardNoteForm{}), org.EditProjectBoardNote)
-								m.Delete("", org.DeleteProjectBoardNote)
+								m.Put("", web.Bind(forms.BoardNoteForm{}), org.EditBoardNote)
+								m.Delete("", org.DeleteBoardNote)
 
 								m.Group("/pin", func() {
-									m.Post("", web.Bind(forms.ProjectBoardNoteForm{}), org.PinProjectBoardNote)
-									m.Delete("", org.UnPinProjectBoardNote)
-									m.Post("/move", org.PinMoveProjectBoardNote)
+									m.Post("", web.Bind(forms.BoardNoteForm{}), org.PinBoardNote)
+									m.Delete("", org.UnPinBoardNote)
+									m.Post("/move", org.PinMoveBoardNote)
 								})
 							})
 						})
@@ -1339,7 +1339,7 @@ func registerRoutes(m *web.Route) {
 		m.Group("/projects", func() {
 			m.Get("", repo.Projects)
 			m.Get("/{id}", repo.ViewProject)
-			m.Group("", func() {
+			m.Group("", func() { //nolint:dupl
 				m.Get("/new", repo.RenderNewProject)
 				m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)
 				m.Group("/{id}", func() {
@@ -1359,17 +1359,17 @@ func registerRoutes(m *web.Route) {
 						m.Post("/move", repo.MoveIssues)
 
 						m.Group("/note", func() {
-							m.Post("", web.Bind(forms.ProjectBoardNoteForm{}), repo.AddProjectBoardNoteToBoard)
-							m.Post("/move", repo.MoveProjectBoardNote)
+							m.Post("", web.Bind(forms.BoardNoteForm{}), repo.AddBoardNoteToBoard)
+							m.Post("/move", repo.MoveBoardNote)
 
 							m.Group("/{noteID}", func() {
-								m.Put("", web.Bind(forms.ProjectBoardNoteForm{}), repo.EditProjectBoardNote)
-								m.Delete("", repo.DeleteProjectBoardNote)
+								m.Put("", web.Bind(forms.BoardNoteForm{}), repo.EditBoardNote)
+								m.Delete("", repo.DeleteBoardNote)
 
 								m.Group("/pin", func() {
-									m.Post("", web.Bind(forms.ProjectBoardNoteForm{}), repo.PinProjectBoardNote)
-									m.Delete("", repo.UnPinProjectBoardNote)
-									m.Post("/move", repo.PinMoveProjectBoardNote)
+									m.Post("", web.Bind(forms.BoardNoteForm{}), repo.PinBoardNote)
+									m.Delete("", repo.UnPinBoardNote)
+									m.Post("/move", repo.PinMoveBoardNote)
 								})
 							})
 						})
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index 4db0306643870..da87f7402f10b 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -533,8 +533,8 @@ type EditProjectBoardForm struct {
 	Color   string `binding:"MaxSize(7)"`
 }
 
-// ProjectBoardNoteForm is a form for editing/creating a note to a board
-type ProjectBoardNoteForm struct {
+// BoardNoteForm is a form for editing/creating a note to a board
+type BoardNoteForm struct {
 	Title       string `binding:"Required;MaxSize(255)"`
 	Content     string
 	MilestoneID int64 `form:"milestoneId"`
diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl
index e694c76116921..21c03b58dbea6 100644
--- a/templates/projects/list.tmpl
+++ b/templates/projects/list.tmpl
@@ -45,11 +45,11 @@
 
 <div class="milestone-list">
 	{{range .Projects}}
-		<li class="milestone-card {{if .FirstPinnedProjectBoardNote}}with-pinned-note{{end}}">
-			{{if .FirstPinnedProjectBoardNote}}
+		<li class="milestone-card {{if .FirstPinnedBoardNote}}with-pinned-note{{end}}">
+			{{if .FirstPinnedBoardNote}}
 				<div class="note-card" data-note="{{.ID}}">
 					{{template "repo/note" (dict
-						"BoardNote" .FirstPinnedProjectBoardNote
+						"ColumnNote" .FirstPinnedBoardNote
 						"root" $
 						"CanWriteProjects" false)
 					}}
diff --git a/templates/projects/milestone_selection.tmpl b/templates/projects/milestone_selection.tmpl
index 86639e3ab1e17..0c63f8e44e301 100644
--- a/templates/projects/milestone_selection.tmpl
+++ b/templates/projects/milestone_selection.tmpl
@@ -33,4 +33,4 @@
 			{{end}}
 		{{end}}
 	</div>
-</div>
\ No newline at end of file
+</div>
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index d77559cc0e3a3..ae2aaf7fd25ba 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -1,11 +1,11 @@
 {{$canWriteProject := and .CanWriteProjects (or (not .Repository) (not .Repository.IsArchived))}}
 
-{{if gt (len .PinnedProjectBoardNotes) 0}}
+{{if gt (len .PinnedColumnNotes) 0}}
 <div id="pinned-notes" {{if .CanWriteProjects}}class="sortable"{{end}}>
-	{{range .PinnedProjectBoardNotes}}
+	{{range .PinnedColumnNotes}}
 		<div class="note-card pinned-card {{if $canWriteProject}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-note="{{.ID}}">
 			{{template "repo/note" (dict
-				"BoardNote" .
+				"ColumnNote" .
 				"root" $
 				"CanWriteProjects" $canWriteProject
 				"RenderModals" false)
@@ -85,7 +85,7 @@
 				<div class="project-column-header">
 					<div class="ui large label project-column-title gt-py-2">
 						<div class="ui small circular grey label project-column-issue-note-count">
-							{{.NumIssuesAndProjectBoardNotes ctx}}
+							{{.NumIssuesAndNotes ctx}}
 						</div>
 						{{.Title}}
 					</div>
@@ -243,12 +243,12 @@
 				<div class="divider"></div>
 
 				<div class="cards-wrapper">
-					{{if or $canWriteProject (index $.ProjectBoardNotesMap .ID)}}
+					{{if or $canWriteProject (index $.ColumnNotesMap .ID)}}
 						<div class="ui cards note-cards {{if $canWriteProject}}{{/* ID 0 is default column which cannot be moved */}}gt-cursor-grab{{end}}" data-url="{{$.Link}}/{{.ID}}/note" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="note-board-{{.ID}}">
-							{{range (index $.ProjectBoardNotesMap .ID)}}
+							{{range (index $.ColumnNotesMap .ID)}}
 								<div class="note-card" data-note="{{.ID}}">
 									{{template "repo/note" (dict
-										"BoardNote" .
+										"ColumnNote" .
 										"root" $
 										"CanWriteProjects" $canWriteProject
 										"RenderModals" true)
diff --git a/templates/repo/note.tmpl b/templates/repo/note.tmpl
index 1c183a1f21b1d..b27157f00b9c5 100644
--- a/templates/repo/note.tmpl
+++ b/templates/repo/note.tmpl
@@ -1,4 +1,4 @@
-{{with .BoardNote}}
+{{with .ColumnNote}}
 {{$backendURL := (print $.root.FeedURL "/projects/" .ProjectID "/" .BoardID "/note/")}}
 {{if not $.root.Repository}}
 	{{$backendURL = (print $.root.FeedURL "/-/projects/" .ProjectID "/" .BoardID "/note/")}}

From afcd5d805396bb520bdeb6148f77a06366864830 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?=
 <zokki.softwareschmiede@gmail.com>
Date: Fri, 9 Feb 2024 04:29:37 +0100
Subject: [PATCH 46/46] fix: prefill of issue form is now from pinned notes
 possible

---
 web_src/js/features/repo-projects.js | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index 5770da55d6fff..41a518cc0d445 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -192,8 +192,11 @@ export function initRepoProject() {
   $('.create-project-issue-from-note').each(function () {
     const anchorLink = $(this);
     const wrapperCard = anchorLink.closest('.note-card');
-    const titleInput = wrapperCard.find('[name="title"]');
-    const contentTextarea = wrapperCard.find('.markdown-text-editor');
+    const showEditModalButton = wrapperCard.find('button[data-modal^="#edit-project-column-note-modal-"]');
+    const editModalId = showEditModalButton.data('modal');
+    const editModal = $(editModalId);
+    const titleInput = editModal.find('[name="title"]');
+    const contentTextarea = editModal.find('.markdown-text-editor');
 
     anchorLink.on('click', () => {
       sessionStorage.setItem('board-note-title', titleInput.val());