2
0

feat(wishlist): add wishlist feature for feature requests
Some checks failed
Build and Release / Lint (push) Failing after 5m36s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 2m37s
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Successful in 3m39s

Implements comprehensive wishlist/feature request system for repositories. Includes categories with colors, voting system, importance ratings (1-5 stars), status tracking (open/planned/in-progress/completed/declined), threaded comments with reactions, and release linking. Adds v2 API endpoints for CRUD operations. Includes repository settings toggle, header tab, and full UI templates for list/view/create. Supports vote counts, importance averages, and comment reactions.
This commit is contained in:
2026-02-02 15:15:56 -05:00
parent 2ba1596b02
commit 36f5d77c9f
31 changed files with 3327 additions and 0 deletions

View File

@@ -430,6 +430,13 @@ func prepareMigrationTasks() []*migration {
newMigration(353, "Create blog_comment table", v1_26.CreateBlogCommentTable),
newMigration(354, "Add allow_comments to blog_post", v1_26.AddAllowCommentsToBlogPost),
newMigration(355, "Create blog_guest_token table", v1_26.CreateBlogGuestTokenTable),
newMigration(356, "Add wishlist_enabled to repository", v1_26.AddWishlistEnabledToRepository),
newMigration(357, "Create wishlist_category table", v1_26.CreateWishlistCategoryTable),
newMigration(358, "Create wishlist_item table", v1_26.CreateWishlistItemTable),
newMigration(359, "Create wishlist_vote table", v1_26.CreateWishlistVoteTable),
newMigration(360, "Create wishlist_importance table", v1_26.CreateWishlistImportanceTable),
newMigration(361, "Create wishlist_comment table", v1_26.CreateWishlistCommentTable),
newMigration(362, "Create wishlist_comment_reaction table", v1_26.CreateWishlistCommentReactionTable),
}
return preparedMigrations
}

View File

@@ -0,0 +1,13 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import "xorm.io/xorm"
func AddWishlistEnabledToRepository(x *xorm.Engine) error {
type Repository struct {
WishlistEnabled bool `xorm:"NOT NULL DEFAULT false"`
}
return x.Sync(new(Repository))
}

View File

@@ -0,0 +1,22 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
func CreateWishlistCategoryTable(x *xorm.Engine) error {
type WishlistCategory struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"VARCHAR(100) NOT NULL"`
Color string `xorm:"VARCHAR(7) NOT NULL DEFAULT '#6c757d'"`
SortOrder int `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
}
return x.Sync(new(WishlistCategory))
}

View File

@@ -0,0 +1,28 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
func CreateWishlistItemTable(x *xorm.Engine) error {
type WishlistItem struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
CategoryID int64 `xorm:"INDEX DEFAULT 0"`
AuthorID int64 `xorm:"INDEX NOT NULL"`
Title string `xorm:"VARCHAR(255) NOT NULL"`
Content string `xorm:"LONGTEXT NOT NULL"`
Status int `xorm:"SMALLINT NOT NULL DEFAULT 0"`
ReleaseID int64 `xorm:"DEFAULT 0"`
VoteCount int `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
ClosedUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
}
return x.Sync(new(WishlistItem))
}

View File

@@ -0,0 +1,20 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
func CreateWishlistVoteTable(x *xorm.Engine) error {
type WishlistVote struct {
ID int64 `xorm:"pk autoincr"`
ItemID int64 `xorm:"UNIQUE(user_item) INDEX NOT NULL"`
UserID int64 `xorm:"UNIQUE(user_item) INDEX NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
}
return x.Sync(new(WishlistVote))
}

View File

@@ -0,0 +1,21 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
func CreateWishlistImportanceTable(x *xorm.Engine) error {
type WishlistImportance struct {
ID int64 `xorm:"pk autoincr"`
ItemID int64 `xorm:"UNIQUE(user_item) INDEX NOT NULL"`
UserID int64 `xorm:"UNIQUE(user_item) INDEX NOT NULL"`
Rating int `xorm:"SMALLINT NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
}
return x.Sync(new(WishlistImportance))
}

View File

@@ -0,0 +1,22 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
func CreateWishlistCommentTable(x *xorm.Engine) error {
type WishlistComment struct {
ID int64 `xorm:"pk autoincr"`
ItemID int64 `xorm:"INDEX NOT NULL"`
UserID int64 `xorm:"INDEX NOT NULL"`
Content string `xorm:"LONGTEXT NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}
return x.Sync(new(WishlistComment))
}

View File

@@ -0,0 +1,21 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
func CreateWishlistCommentReactionTable(x *xorm.Engine) error {
type WishlistCommentReaction struct {
ID int64 `xorm:"pk autoincr"`
CommentID int64 `xorm:"INDEX NOT NULL"`
UserID int64 `xorm:"INDEX NOT NULL"`
IsLike bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
}
return x.Sync(new(WishlistCommentReaction))
}

View File

@@ -220,6 +220,7 @@ type Repository struct {
Topics []string `xorm:"TEXT JSON"`
SubscriptionsEnabled bool `xorm:"NOT NULL DEFAULT false"`
BlogEnabled bool `xorm:"NOT NULL DEFAULT false"`
WishlistEnabled bool `xorm:"NOT NULL DEFAULT false"`
ObjectFormatName string `xorm:"VARCHAR(6) NOT NULL DEFAULT 'sha1'"`
TrustModel TrustModelType

View File

@@ -0,0 +1,83 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package wishlist
import (
"context"
"fmt"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
// WishlistCategory represents a user-defined category for wishlist items.
type WishlistCategory struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"VARCHAR(100) NOT NULL"`
Color string `xorm:"VARCHAR(7) NOT NULL DEFAULT '#6c757d'"`
SortOrder int `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
}
func init() {
db.RegisterModel(new(WishlistCategory))
}
// GetCategoriesByRepoID returns all categories for a repo, ordered by sort_order.
func GetCategoriesByRepoID(ctx context.Context, repoID int64) ([]*WishlistCategory, error) {
cats := make([]*WishlistCategory, 0, 10)
return cats, db.GetEngine(ctx).Where("repo_id = ?", repoID).
OrderBy("sort_order ASC, id ASC").
Find(&cats)
}
// GetCategoryByID returns a single category by ID.
func GetCategoryByID(ctx context.Context, id int64) (*WishlistCategory, error) {
c := &WishlistCategory{}
has, err := db.GetEngine(ctx).ID(id).Get(c)
if err != nil {
return nil, err
}
if !has {
return nil, fmt.Errorf("wishlist category %d not found", id)
}
return c, nil
}
// CreateCategory inserts a new category.
func CreateCategory(ctx context.Context, c *WishlistCategory) error {
_, err := db.GetEngine(ctx).Insert(c)
return err
}
// UpdateCategory updates an existing category.
func UpdateCategory(ctx context.Context, c *WishlistCategory) error {
_, err := db.GetEngine(ctx).ID(c.ID).Cols("name", "color", "sort_order").Update(c)
return err
}
// DeleteCategory removes a category. Items referencing it will have category_id set to 0.
func DeleteCategory(ctx context.Context, id int64) error {
// Clear category reference from items
_, err := db.GetEngine(ctx).Exec("UPDATE wishlist_item SET category_id = 0 WHERE category_id = ?", id)
if err != nil {
return err
}
_, err = db.GetEngine(ctx).ID(id).Delete(new(WishlistCategory))
return err
}
// GetCategoriesMapByRepoID returns a map of category ID to category for a repo.
func GetCategoriesMapByRepoID(ctx context.Context, repoID int64) (map[int64]*WishlistCategory, error) {
cats, err := GetCategoriesByRepoID(ctx, repoID)
if err != nil {
return nil, err
}
m := make(map[int64]*WishlistCategory, len(cats))
for _, c := range cats {
m[c.ID] = c
}
return m, nil
}

View File

@@ -0,0 +1,126 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package wishlist
import (
"context"
"fmt"
"code.gitcaddy.com/server/v3/models/db"
user_model "code.gitcaddy.com/server/v3/models/user"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
// WishlistComment represents a comment on a wishlist item.
type WishlistComment struct {
ID int64 `xorm:"pk autoincr"`
ItemID int64 `xorm:"INDEX NOT NULL"`
UserID int64 `xorm:"INDEX NOT NULL"`
Content string `xorm:"LONGTEXT NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
User *user_model.User `xorm:"-"`
}
func init() {
db.RegisterModel(new(WishlistComment))
}
// LoadUser loads the comment author.
func (c *WishlistComment) LoadUser(ctx context.Context) error {
if c.User != nil {
return nil
}
u, err := user_model.GetUserByID(ctx, c.UserID)
if err != nil {
return err
}
c.User = u
return nil
}
// GetCommentsByItemID returns all comments for a wishlist item, with users loaded.
func GetCommentsByItemID(ctx context.Context, itemID int64) ([]*WishlistComment, error) {
comments := make([]*WishlistComment, 0, 20)
err := db.GetEngine(ctx).Where("item_id = ?", itemID).
OrderBy("created_unix ASC").
Find(&comments)
if err != nil {
return nil, err
}
for _, c := range comments {
_ = c.LoadUser(ctx)
}
return comments, nil
}
// GetCommentByID returns a single comment by ID.
func GetCommentByID(ctx context.Context, id int64) (*WishlistComment, error) {
c := &WishlistComment{}
has, err := db.GetEngine(ctx).ID(id).Get(c)
if err != nil {
return nil, err
}
if !has {
return nil, fmt.Errorf("wishlist comment %d not found", id)
}
return c, nil
}
// CreateComment inserts a new comment.
func CreateComment(ctx context.Context, c *WishlistComment) error {
_, err := db.GetEngine(ctx).Insert(c)
return err
}
// DeleteComment removes a comment and its reactions.
func DeleteComment(ctx context.Context, id int64) error {
if err := DeleteCommentReactionsByCommentID(ctx, id); err != nil {
return err
}
_, err := db.GetEngine(ctx).ID(id).Delete(new(WishlistComment))
return err
}
// CountCommentsByItemID returns the number of comments on an item.
func CountCommentsByItemID(ctx context.Context, itemID int64) (int64, error) {
return db.GetEngine(ctx).Where("item_id = ?", itemID).Count(new(WishlistComment))
}
// CountCommentsByItemIDBatch returns comment counts for multiple items.
func CountCommentsByItemIDBatch(ctx context.Context, itemIDs []int64) (map[int64]int64, error) {
result := make(map[int64]int64, len(itemIDs))
if len(itemIDs) == 0 {
return result, nil
}
type countRow struct {
ItemID int64 `xorm:"item_id"`
Cnt int64 `xorm:"cnt"`
}
var rows []countRow
err := db.GetEngine(ctx).Table("wishlist_comment").
Select("item_id, COUNT(*) AS cnt").
In("item_id", itemIDs).
GroupBy("item_id").
Find(&rows)
if err != nil {
return nil, err
}
for _, r := range rows {
result[r.ItemID] = r.Cnt
}
return result, nil
}
// DeleteCommentsByItemID removes all comments for an item.
func DeleteCommentsByItemID(ctx context.Context, itemID int64) error {
// Delete reactions first
if err := DeleteCommentReactionsByItemID(ctx, itemID); err != nil {
return err
}
_, err := db.GetEngine(ctx).Where("item_id = ?", itemID).Delete(new(WishlistComment))
return err
}

View File

@@ -0,0 +1,158 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package wishlist
import (
"context"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
// WishlistCommentReaction represents a thumbs up/down reaction on a wishlist comment.
type WishlistCommentReaction struct {
ID int64 `xorm:"pk autoincr"`
CommentID int64 `xorm:"INDEX NOT NULL"`
UserID int64 `xorm:"INDEX NOT NULL"`
IsLike bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
}
func init() {
db.RegisterModel(new(WishlistCommentReaction))
}
// CommentReactionCounts holds aggregated reaction counts.
type CommentReactionCounts struct {
Likes int64
Dislikes int64
}
// ToggleCommentReaction creates, updates, or removes a reaction.
func ToggleCommentReaction(ctx context.Context, commentID, userID int64, isLike bool) (reacted bool, err error) {
existing := &WishlistCommentReaction{}
has, err := db.GetEngine(ctx).Where("comment_id = ? AND user_id = ?", commentID, userID).Get(existing)
if err != nil {
return false, err
}
if has {
if existing.IsLike == isLike {
// Same type — toggle off
_, err = db.GetEngine(ctx).ID(existing.ID).Delete(new(WishlistCommentReaction))
return false, err
}
// Different type — switch
existing.IsLike = isLike
_, err = db.GetEngine(ctx).ID(existing.ID).Cols("is_like").Update(existing)
return true, err
}
_, err = db.GetEngine(ctx).Insert(&WishlistCommentReaction{
CommentID: commentID,
UserID: userID,
IsLike: isLike,
})
return true, err
}
// GetCommentReactionCounts returns aggregated counts for a comment.
func GetCommentReactionCounts(ctx context.Context, commentID int64) (*CommentReactionCounts, error) {
likes, err := db.GetEngine(ctx).Where("comment_id = ? AND is_like = ?", commentID, true).Count(new(WishlistCommentReaction))
if err != nil {
return nil, err
}
dislikes, err := db.GetEngine(ctx).Where("comment_id = ? AND is_like = ?", commentID, false).Count(new(WishlistCommentReaction))
if err != nil {
return nil, err
}
return &CommentReactionCounts{Likes: likes, Dislikes: dislikes}, nil
}
// GetCommentReactionCountsBatch returns reaction counts for multiple comments.
func GetCommentReactionCountsBatch(ctx context.Context, commentIDs []int64) (map[int64]*CommentReactionCounts, error) {
result := make(map[int64]*CommentReactionCounts, len(commentIDs))
for _, id := range commentIDs {
result[id] = &CommentReactionCounts{}
}
if len(commentIDs) == 0 {
return result, nil
}
type countRow struct {
CommentID int64 `xorm:"comment_id"`
IsLike bool `xorm:"is_like"`
Cnt int64 `xorm:"cnt"`
}
var rows []countRow
err := db.GetEngine(ctx).Table("wishlist_comment_reaction").
Select("comment_id, is_like, COUNT(*) AS cnt").
In("comment_id", commentIDs).
GroupBy("comment_id, is_like").
Find(&rows)
if err != nil {
return nil, err
}
for _, r := range rows {
counts, ok := result[r.CommentID]
if !ok {
counts = &CommentReactionCounts{}
result[r.CommentID] = counts
}
if r.IsLike {
counts.Likes = r.Cnt
} else {
counts.Dislikes = r.Cnt
}
}
return result, nil
}
// GetUserCommentReaction returns the user's reaction for a comment, or nil.
func GetUserCommentReaction(ctx context.Context, commentID, userID int64) (*WishlistCommentReaction, error) {
r := &WishlistCommentReaction{}
has, err := db.GetEngine(ctx).Where("comment_id = ? AND user_id = ?", commentID, userID).Get(r)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return r, nil
}
// GetUserCommentReactionsBatch returns the user's reactions for multiple comments.
func GetUserCommentReactionsBatch(ctx context.Context, commentIDs []int64, userID int64) (map[int64]*WishlistCommentReaction, error) {
result := make(map[int64]*WishlistCommentReaction, len(commentIDs))
if len(commentIDs) == 0 || userID == 0 {
return result, nil
}
var reactions []*WishlistCommentReaction
err := db.GetEngine(ctx).
Where("user_id = ?", userID).
In("comment_id", commentIDs).
Find(&reactions)
if err != nil {
return nil, err
}
for _, r := range reactions {
result[r.CommentID] = r
}
return result, nil
}
// DeleteCommentReactionsByCommentID removes all reactions for a comment.
func DeleteCommentReactionsByCommentID(ctx context.Context, commentID int64) error {
_, err := db.GetEngine(ctx).Where("comment_id = ?", commentID).Delete(new(WishlistCommentReaction))
return err
}
// DeleteCommentReactionsByItemID removes all comment reactions for an item's comments.
func DeleteCommentReactionsByItemID(ctx context.Context, itemID int64) error {
_, err := db.GetEngine(ctx).
Where("comment_id IN (SELECT id FROM wishlist_comment WHERE item_id = ?)", itemID).
Delete(new(WishlistCommentReaction))
return err
}

View File

@@ -0,0 +1,169 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package wishlist
import (
"context"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
// Importance rating levels.
const (
ImportanceNotInterested = 0
ImportanceImportant = 1
ImportanceRequired = 2
)
// WishlistImportance represents a user's importance rating on a wishlist item.
type WishlistImportance struct {
ID int64 `xorm:"pk autoincr"`
ItemID int64 `xorm:"UNIQUE(user_item) INDEX NOT NULL"`
UserID int64 `xorm:"UNIQUE(user_item) INDEX NOT NULL"`
Rating int `xorm:"SMALLINT NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
}
func init() {
db.RegisterModel(new(WishlistImportance))
}
// ImportanceSummary holds aggregated importance counts for an item.
type ImportanceSummary struct {
NotInterested int64
Important int64
Required int64
}
// SetImportance upserts a user's importance rating on an item.
func SetImportance(ctx context.Context, userID, itemID int64, rating int) error {
existing := &WishlistImportance{}
has, err := db.GetEngine(ctx).Where("user_id = ? AND item_id = ?", userID, itemID).Get(existing)
if err != nil {
return err
}
if has {
if existing.Rating == rating {
return nil // no change
}
existing.Rating = rating
_, err = db.GetEngine(ctx).ID(existing.ID).Cols("rating").Update(existing)
return err
}
_, err = db.GetEngine(ctx).Insert(&WishlistImportance{
ItemID: itemID,
UserID: userID,
Rating: rating,
})
return err
}
// GetUserImportance returns the user's importance rating for an item, or -1 if not set.
func GetUserImportance(ctx context.Context, userID, itemID int64) (int, error) {
imp := &WishlistImportance{}
has, err := db.GetEngine(ctx).Where("user_id = ? AND item_id = ?", userID, itemID).Get(imp)
if err != nil {
return -1, err
}
if !has {
return -1, nil
}
return imp.Rating, nil
}
// GetImportanceSummary returns aggregated importance counts for an item.
func GetImportanceSummary(ctx context.Context, itemID int64) (*ImportanceSummary, error) {
summary := &ImportanceSummary{}
type countRow struct {
Rating int `xorm:"rating"`
Cnt int64 `xorm:"cnt"`
}
var rows []countRow
err := db.GetEngine(ctx).Table("wishlist_importance").
Select("rating, COUNT(*) AS cnt").
Where("item_id = ?", itemID).
GroupBy("rating").
Find(&rows)
if err != nil {
return nil, err
}
for _, r := range rows {
switch r.Rating {
case ImportanceNotInterested:
summary.NotInterested = r.Cnt
case ImportanceImportant:
summary.Important = r.Cnt
case ImportanceRequired:
summary.Required = r.Cnt
}
}
return summary, nil
}
// GetImportanceSummaryBatch returns importance summaries for multiple items.
func GetImportanceSummaryBatch(ctx context.Context, itemIDs []int64) (map[int64]*ImportanceSummary, error) {
result := make(map[int64]*ImportanceSummary, len(itemIDs))
for _, id := range itemIDs {
result[id] = &ImportanceSummary{}
}
if len(itemIDs) == 0 {
return result, nil
}
type countRow struct {
ItemID int64 `xorm:"item_id"`
Rating int `xorm:"rating"`
Cnt int64 `xorm:"cnt"`
}
var rows []countRow
err := db.GetEngine(ctx).Table("wishlist_importance").
Select("item_id, rating, COUNT(*) AS cnt").
In("item_id", itemIDs).
GroupBy("item_id, rating").
Find(&rows)
if err != nil {
return nil, err
}
for _, r := range rows {
s, ok := result[r.ItemID]
if !ok {
s = &ImportanceSummary{}
result[r.ItemID] = s
}
switch r.Rating {
case ImportanceNotInterested:
s.NotInterested = r.Cnt
case ImportanceImportant:
s.Important = r.Cnt
case ImportanceRequired:
s.Required = r.Cnt
}
}
return result, nil
}
// GetUserImportanceBatch returns the user's importance ratings for multiple items.
func GetUserImportanceBatch(ctx context.Context, userID int64, itemIDs []int64) (map[int64]int, error) {
result := make(map[int64]int, len(itemIDs))
if len(itemIDs) == 0 || userID == 0 {
return result, nil
}
var imps []*WishlistImportance
err := db.GetEngine(ctx).
Where("user_id = ?", userID).
In("item_id", itemIDs).
Find(&imps)
if err != nil {
return nil, err
}
for _, imp := range imps {
result[imp.ItemID] = imp.Rating
}
return result, nil
}

View File

@@ -0,0 +1,222 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package wishlist
import (
"context"
"fmt"
"code.gitcaddy.com/server/v3/models/db"
repo_model "code.gitcaddy.com/server/v3/models/repo"
user_model "code.gitcaddy.com/server/v3/models/user"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
// WishlistItemStatus represents the state of a wishlist item.
type WishlistItemStatus int
const (
WishlistItemOpen WishlistItemStatus = 0
WishlistItemCompleted WishlistItemStatus = 1
WishlistItemWillNotDo WishlistItemStatus = 2
)
// String returns a human-readable label.
func (s WishlistItemStatus) String() string {
switch s {
case WishlistItemOpen:
return "open"
case WishlistItemCompleted:
return "completed"
case WishlistItemWillNotDo:
return "will_not_do"
default:
return "unknown"
}
}
// IsClosed returns true if the item is completed or rejected.
func (s WishlistItemStatus) IsClosed() bool {
return s == WishlistItemCompleted || s == WishlistItemWillNotDo
}
// WishlistItem represents a feature request in a repository wishlist.
type WishlistItem struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
CategoryID int64 `xorm:"INDEX DEFAULT 0"`
AuthorID int64 `xorm:"INDEX NOT NULL"`
Title string `xorm:"VARCHAR(255) NOT NULL"`
Content string `xorm:"LONGTEXT NOT NULL"`
RenderedContent string `xorm:"-"`
Status WishlistItemStatus `xorm:"SMALLINT NOT NULL DEFAULT 0"`
ReleaseID int64 `xorm:"DEFAULT 0"`
VoteCount int `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
ClosedUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
Author *user_model.User `xorm:"-"`
Category *WishlistCategory `xorm:"-"`
Release *repo_model.Release `xorm:"-"`
}
func init() {
db.RegisterModel(new(WishlistItem))
}
// LoadAuthor loads the author user.
func (item *WishlistItem) LoadAuthor(ctx context.Context) error {
if item.Author != nil {
return nil
}
u, err := user_model.GetUserByID(ctx, item.AuthorID)
if err != nil {
return err
}
item.Author = u
return nil
}
// LoadCategory loads the category. Returns nil category if CategoryID is 0.
func (item *WishlistItem) LoadCategory(ctx context.Context) error {
if item.Category != nil || item.CategoryID == 0 {
return nil
}
c, err := GetCategoryByID(ctx, item.CategoryID)
if err != nil {
// Category may have been deleted
return nil //nolint:nilerr
}
item.Category = c
return nil
}
// LoadRelease loads the linked release for completed items.
func (item *WishlistItem) LoadRelease(ctx context.Context) error {
if item.Release != nil || item.ReleaseID == 0 {
return nil
}
r, err := repo_model.GetReleaseByID(ctx, item.ReleaseID)
if err != nil {
return nil //nolint:nilerr
}
item.Release = r
return nil
}
// WishlistItemSearchOptions configures the wishlist item query.
type WishlistItemSearchOptions struct {
RepoID int64
CategoryID int64 // 0 = all categories, >0 = specific
Status WishlistItemStatus // -1 = all
AllClosed bool // if true, match Completed OR WillNotDo
SortBy string // "votes" (default), "newest", "importance"
Page int
PageSize int
}
// SearchWishlistItems returns filtered, paginated wishlist items.
func SearchWishlistItems(ctx context.Context, opts *WishlistItemSearchOptions) ([]*WishlistItem, int64, error) {
if opts.PageSize <= 0 {
opts.PageSize = 20
}
if opts.Page <= 0 {
opts.Page = 1
}
// Count query
countSess := db.GetEngine(ctx).Where("wishlist_item.repo_id = ?", opts.RepoID)
if opts.AllClosed {
countSess = countSess.And("wishlist_item.status > 0")
} else if opts.Status >= 0 {
countSess = countSess.And("wishlist_item.status = ?", opts.Status)
}
if opts.CategoryID > 0 {
countSess = countSess.And("wishlist_item.category_id = ?", opts.CategoryID)
}
count, err := countSess.Count(new(WishlistItem))
if err != nil {
return nil, 0, err
}
// Find query (re-create conditions since xorm reuses state)
findSess := db.GetEngine(ctx).Where("wishlist_item.repo_id = ?", opts.RepoID)
if opts.AllClosed {
findSess = findSess.And("wishlist_item.status > 0")
} else if opts.Status >= 0 {
findSess = findSess.And("wishlist_item.status = ?", opts.Status)
}
if opts.CategoryID > 0 {
findSess = findSess.And("wishlist_item.category_id = ?", opts.CategoryID)
}
switch opts.SortBy {
case "newest":
findSess = findSess.OrderBy("wishlist_item.created_unix DESC")
case "importance":
findSess = findSess.OrderBy(
"(SELECT COALESCE(SUM(rating), 0) FROM wishlist_importance WHERE wishlist_importance.item_id = wishlist_item.id) DESC, wishlist_item.vote_count DESC",
)
default: // "votes"
findSess = findSess.OrderBy("wishlist_item.vote_count DESC, wishlist_item.created_unix DESC")
}
items := make([]*WishlistItem, 0, opts.PageSize)
err = findSess.
Limit(opts.PageSize, (opts.Page-1)*opts.PageSize).
Find(&items)
return items, count, err
}
// GetWishlistItemByID returns a single wishlist item by ID.
func GetWishlistItemByID(ctx context.Context, id int64) (*WishlistItem, error) {
item := &WishlistItem{}
has, err := db.GetEngine(ctx).ID(id).Get(item)
if err != nil {
return nil, err
}
if !has {
return nil, fmt.Errorf("wishlist item %d not found", id)
}
return item, nil
}
// CreateWishlistItem inserts a new wishlist item.
func CreateWishlistItem(ctx context.Context, item *WishlistItem) error {
_, err := db.GetEngine(ctx).Insert(item)
return err
}
// UpdateWishlistItem updates an existing wishlist item.
func UpdateWishlistItem(ctx context.Context, item *WishlistItem) error {
_, err := db.GetEngine(ctx).ID(item.ID).AllCols().Update(item)
return err
}
// CloseWishlistItem sets status to completed or will-not-do.
func CloseWishlistItem(ctx context.Context, id int64, status WishlistItemStatus, releaseID int64) error {
_, err := db.GetEngine(ctx).ID(id).Cols("status", "release_id", "closed_unix").Update(&WishlistItem{
Status: status,
ReleaseID: releaseID,
ClosedUnix: timeutil.TimeStampNow(),
})
return err
}
// ReopenWishlistItem sets an item back to open.
func ReopenWishlistItem(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Cols("status", "release_id", "closed_unix").Update(&WishlistItem{
Status: WishlistItemOpen,
ReleaseID: 0,
ClosedUnix: 0,
})
return err
}
// CountOpenWishlistItems returns the count of open items for a repo (for header tab badge).
func CountOpenWishlistItems(ctx context.Context, repoID int64) (int64, error) {
return db.GetEngine(ctx).Where("repo_id = ? AND status = ?", repoID, WishlistItemOpen).Count(new(WishlistItem))
}

View File

@@ -0,0 +1,99 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package wishlist
import (
"context"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
const MaxVotesPerRepo = 3
// WishlistVote represents a user's vote on a wishlist item.
type WishlistVote struct {
ID int64 `xorm:"pk autoincr"`
ItemID int64 `xorm:"UNIQUE(user_item) INDEX NOT NULL"`
UserID int64 `xorm:"UNIQUE(user_item) INDEX NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
}
func init() {
db.RegisterModel(new(WishlistVote))
}
// CountUserActiveVotesInRepo counts votes by a user on OPEN items in a repo.
// Votes on closed items don't count — they're "returned" to the user's budget.
func CountUserActiveVotesInRepo(ctx context.Context, userID, repoID int64) (int64, error) {
return db.GetEngine(ctx).
Table("wishlist_vote").
Join("INNER", "wishlist_item", "wishlist_vote.item_id = wishlist_item.id").
Where("wishlist_vote.user_id = ? AND wishlist_item.repo_id = ? AND wishlist_item.status = ?",
userID, repoID, WishlistItemOpen).
Count(new(WishlistVote))
}
// HasUserVotedItem checks if a user has voted on a specific item.
func HasUserVotedItem(ctx context.Context, userID, itemID int64) (bool, error) {
return db.GetEngine(ctx).Where("user_id = ? AND item_id = ?", userID, itemID).Exist(new(WishlistVote))
}
// ToggleVote adds or removes a vote. Returns (voted, error).
// If adding and user already has MaxVotesPerRepo active votes, returns (false, nil).
func ToggleVote(ctx context.Context, userID, itemID, repoID int64) (bool, error) {
existing := &WishlistVote{}
has, err := db.GetEngine(ctx).Where("user_id = ? AND item_id = ?", userID, itemID).Get(existing)
if err != nil {
return false, err
}
if has {
// Remove vote
if _, err := db.GetEngine(ctx).ID(existing.ID).Delete(new(WishlistVote)); err != nil {
return false, err
}
// Decrement denormalized count
_, err = db.GetEngine(ctx).Exec("UPDATE wishlist_item SET vote_count = vote_count - 1 WHERE id = ? AND vote_count > 0", itemID)
return false, err
}
// Check budget
activeVotes, err := CountUserActiveVotesInRepo(ctx, userID, repoID)
if err != nil {
return false, err
}
if activeVotes >= MaxVotesPerRepo {
return false, nil
}
// Add vote
if _, err := db.GetEngine(ctx).Insert(&WishlistVote{
ItemID: itemID,
UserID: userID,
}); err != nil {
return false, err
}
// Increment denormalized count
_, err = db.GetEngine(ctx).Exec("UPDATE wishlist_item SET vote_count = vote_count + 1 WHERE id = ?", itemID)
return true, err
}
// GetUserVotedItemIDs returns the item IDs a user has voted on in a repo.
func GetUserVotedItemIDs(ctx context.Context, userID, repoID int64) (map[int64]bool, error) {
var votes []*WishlistVote
err := db.GetEngine(ctx).
Table("wishlist_vote").
Join("INNER", "wishlist_item", "wishlist_vote.item_id = wishlist_item.id").
Where("wishlist_vote.user_id = ? AND wishlist_item.repo_id = ?", userID, repoID).
Find(&votes)
if err != nil {
return nil, err
}
m := make(map[int64]bool, len(votes))
for _, v := range votes {
m[v.ItemID] = true
}
return m, nil
}

View File

@@ -163,6 +163,13 @@ const (
BlogDisabled ErrorCode = "BLOG_DISABLED"
)
// Wishlist errors (WISHLIST_)
const (
WishlistItemNotFound ErrorCode = "WISHLIST_ITEM_NOT_FOUND"
WishlistDisabled ErrorCode = "WISHLIST_DISABLED"
WishlistVoteBudget ErrorCode = "WISHLIST_VOTE_BUDGET_EXCEEDED"
)
// errorInfo contains metadata about an error code
type errorInfo struct {
Message string
@@ -287,6 +294,11 @@ var errorCatalog = map[ErrorCode]errorInfo{
// Blog errors
BlogPostNotFound: {"Blog post not found", http.StatusNotFound},
BlogDisabled: {"Blogs are disabled", http.StatusForbidden},
// Wishlist errors
WishlistItemNotFound: {"Wishlist item not found", http.StatusNotFound},
WishlistDisabled: {"Wishlist is disabled for this repository", http.StatusForbidden},
WishlistVoteBudget: {"Vote budget exceeded for this repository", http.StatusConflict},
}
// Message returns the human-readable message for an error code

View File

@@ -0,0 +1,91 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
import "time"
// WishlistCategoryV2 represents a wishlist category in the V2 API.
type WishlistCategoryV2 struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
SortOrder int `json:"sort_order"`
}
// WishlistItemV2 represents a wishlist item in the V2 API.
type WishlistItemV2 struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content,omitempty"`
Status string `json:"status"`
VoteCount int `json:"vote_count"`
Category *WishlistCategoryV2 `json:"category,omitempty"`
Author *BlogAuthorV2 `json:"author,omitempty"`
Release *WishlistReleaseRefV2 `json:"release,omitempty"`
Importance *WishlistImportanceV2 `json:"importance,omitempty"`
CommentCount int64 `json:"comment_count"`
UserVoted bool `json:"user_voted,omitempty"`
UserImportance *int `json:"user_importance,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
HTMLURL string `json:"html_url"`
}
// WishlistReleaseRefV2 is a reference to a release.
type WishlistReleaseRefV2 struct {
ID int64 `json:"id"`
TagName string `json:"tag_name"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
}
// WishlistImportanceV2 holds importance summary counts.
type WishlistImportanceV2 struct {
NotInterested int64 `json:"not_interested"`
Important int64 `json:"important"`
Required int64 `json:"required"`
}
// WishlistItemListV2 is the response for listing wishlist items.
type WishlistItemListV2 struct {
Items []*WishlistItemV2 `json:"items"`
TotalCount int64 `json:"total_count"`
HasMore bool `json:"has_more"`
}
// WishlistCommentV2 represents a comment in the V2 API.
type WishlistCommentV2 struct {
ID int64 `json:"id"`
Content string `json:"content"`
Author *BlogAuthorV2 `json:"author,omitempty"`
Likes int64 `json:"likes"`
Dislikes int64 `json:"dislikes"`
UserLiked *bool `json:"user_liked,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateWishlistItemV2Option is the request for creating a wishlist item.
type CreateWishlistItemV2Option struct {
Title string `json:"title" binding:"Required;MaxSize(255)"`
Content string `json:"content" binding:"Required"`
CategoryID int64 `json:"category_id"`
}
// SetWishlistImportanceV2Option is the request for setting importance.
type SetWishlistImportanceV2Option struct {
Rating int `json:"rating" binding:"Range(0,2)"`
}
// CloseWishlistItemV2Option is the request for closing an item.
type CloseWishlistItemV2Option struct {
Status string `json:"status" binding:"Required"` // "completed" or "will_not_do"
ReleaseID int64 `json:"release_id"`
}
// CreateWishlistCommentV2Option is the request for creating a comment.
type CreateWishlistCommentV2Option struct {
Content string `json:"content" binding:"Required"`
}

View File

@@ -2058,6 +2058,70 @@
"repo.settings.blog.enable_desc": "Allow blog posts to be created and published from this repository.",
"repo.settings.blog.enable_help": "When enabled, repo members with write access can create, edit, and publish blog posts. Subscribers and watchers will be notified on publish.",
"repo.settings.blog.saved": "Blog settings saved.",
"repo.wishlist": "Wishlist",
"repo.wishlist.new_item": "New Request",
"repo.wishlist.title": "Title",
"repo.wishlist.title_placeholder": "Brief description of the feature request",
"repo.wishlist.content": "Description",
"repo.wishlist.content_placeholder": "Describe the feature request in detail. Markdown is supported.",
"repo.wishlist.content_help": "Markdown formatting is supported.",
"repo.wishlist.category": "Category",
"repo.wishlist.no_category": "No category",
"repo.wishlist.submit": "Submit Request",
"repo.wishlist.sort_votes": "Most Voted",
"repo.wishlist.sort_newest": "Newest",
"repo.wishlist.sort_importance": "Most Important",
"repo.wishlist.no_items": "No wishlist items yet.",
"repo.wishlist.no_items_hint": "Be the first to submit a feature request!",
"repo.wishlist.your_votes": "Your Votes",
"repo.wishlist.votes_remaining": "remaining",
"repo.wishlist.status": "Status",
"repo.wishlist.filter_open": "Open",
"repo.wishlist.filter_closed": "Closed",
"repo.wishlist.categories": "Categories",
"repo.wishlist.all_categories": "All categories",
"repo.wishlist.prev": "Previous",
"repo.wishlist.next": "Next",
"repo.wishlist.page_info": "Page %d of %d",
"repo.wishlist.back_to_list": "Back to list",
"repo.wishlist.status_open": "Open",
"repo.wishlist.status_completed": "Completed",
"repo.wishlist.status_will_not_do": "Will Not Do",
"repo.wishlist.linked_release": "Linked to release",
"repo.wishlist.votes": "Votes",
"repo.wishlist.votes_label": "votes",
"repo.wishlist.add_vote": "Vote",
"repo.wishlist.remove_vote": "Remove Vote",
"repo.wishlist.no_votes_left": "You have no votes remaining in this repository.",
"repo.wishlist.importance": "Is this important to you?",
"repo.wishlist.not_interested": "Not Interested",
"repo.wishlist.important": "Important",
"repo.wishlist.required": "Required",
"repo.wishlist.comments": "Comments",
"repo.wishlist.comment_placeholder": "Write a comment...",
"repo.wishlist.add_comment": "Comment",
"repo.wishlist.no_release": "No release linked",
"repo.wishlist.close_completed": "Close as Completed",
"repo.wishlist.close_will_not_do": "Close as Will Not Do",
"repo.wishlist.reopen": "Reopen",
"repo.wishlist.item_closed": "Wishlist item has been closed.",
"repo.wishlist.item_reopened": "Wishlist item has been reopened.",
"repo.wishlist.cannot_vote_closed": "Cannot vote on a closed item.",
"repo.settings.wishlist": "Wishlist",
"repo.settings.wishlist.enable": "Enable Wishlist",
"repo.settings.wishlist.enable_help": "When enabled, a Wishlist tab appears on the repository where users can submit and vote on feature requests.",
"repo.settings.wishlist.saved": "Wishlist settings saved.",
"repo.settings.wishlist.categories": "Categories",
"repo.settings.wishlist.category_name": "Name",
"repo.settings.wishlist.category_name_placeholder": "e.g. Feature, Bug Fix, Enhancement",
"repo.settings.wishlist.category_color": "Color",
"repo.settings.wishlist.category_sort": "Sort Order",
"repo.settings.wishlist.no_categories": "No categories defined. Add one below.",
"repo.settings.wishlist.add_category": "Add Category",
"repo.settings.wishlist.category_created": "Category created.",
"repo.settings.wishlist.category_saved": "Category saved.",
"repo.settings.wishlist.category_deleted": "Category deleted.",
"repo.settings.wishlist.category_delete_confirm": "Are you sure? Items in this category will become uncategorized.",
"repo.wiki": "Wiki",
"repo.wiki.welcome": "Welcome to the Wiki.",
"repo.wiki.welcome_desc": "The wiki lets you write and share documentation with collaborators.",

View File

@@ -189,6 +189,23 @@ func Routes() *web.Router {
}, reqToken())
})
// Wishlist v2 API - repository wishlist endpoints
m.Group("/repos/{owner}/{repo}/wishlist", func() {
m.Get("/items", repoAssignment(), ListWishlistItemsV2)
m.Get("/items/{id}", repoAssignment(), GetWishlistItemV2)
m.Get("/items/{id}/comments", repoAssignment(), ListWishlistCommentsV2)
m.Get("/categories", repoAssignment(), ListWishlistCategoriesV2)
m.Group("", func() {
m.Post("/items", repoAssignment(), web.Bind(api.CreateWishlistItemV2Option{}), CreateWishlistItemV2)
m.Post("/items/{id}/vote", repoAssignment(), WishlistVoteToggleV2)
m.Post("/items/{id}/importance", repoAssignment(), web.Bind(api.SetWishlistImportanceV2Option{}), WishlistSetImportanceV2)
m.Post("/items/{id}/comments", repoAssignment(), web.Bind(api.CreateWishlistCommentV2Option{}), CreateWishlistCommentV2)
m.Post("/items/{id}/close", repoAssignment(), web.Bind(api.CloseWishlistItemV2Option{}), CloseWishlistItemV2)
m.Post("/items/{id}/reopen", repoAssignment(), ReopenWishlistItemV2)
}, reqToken())
})
// Hidden folders API - manage hidden folders for a repository
m.Group("/repos/{owner}/{repo}/hidden-folders", func() {
m.Get("", repoAssignment(), ListHiddenFoldersV2)

514
routers/api/v2/wishlist.go Normal file
View File

@@ -0,0 +1,514 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v2
import (
"net/http"
wishlist_model "code.gitcaddy.com/server/v3/models/wishlist"
apierrors "code.gitcaddy.com/server/v3/modules/errors"
api "code.gitcaddy.com/server/v3/modules/structs"
"code.gitcaddy.com/server/v3/modules/web"
"code.gitcaddy.com/server/v3/services/context"
)
func toWishlistCategoryV2(cat *wishlist_model.WishlistCategory) *api.WishlistCategoryV2 {
if cat == nil {
return nil
}
return &api.WishlistCategoryV2{
ID: cat.ID,
Name: cat.Name,
Color: cat.Color,
SortOrder: cat.SortOrder,
}
}
func toWishlistItemV2(ctx *context.APIContext, item *wishlist_model.WishlistItem) *api.WishlistItemV2 {
result := &api.WishlistItemV2{
ID: item.ID,
Title: item.Title,
Content: item.Content,
Status: item.Status.String(),
VoteCount: item.VoteCount,
CreatedAt: item.CreatedUnix.AsTime(),
UpdatedAt: item.UpdatedUnix.AsTime(),
HTMLURL: ctx.Repo.Repository.HTMLURL() + "/wishlist/" + ctx.PathParam("id"),
}
if item.ClosedUnix > 0 {
t := item.ClosedUnix.AsTime()
result.ClosedAt = &t
}
if item.Author != nil {
result.Author = &api.BlogAuthorV2{
ID: item.Author.ID,
Username: item.Author.Name,
Name: item.Author.FullName,
AvatarURL: item.Author.AvatarLink(ctx),
}
}
if item.Category != nil {
result.Category = toWishlistCategoryV2(item.Category)
}
if item.Release != nil {
result.Release = &api.WishlistReleaseRefV2{
ID: item.Release.ID,
TagName: item.Release.TagName,
Title: item.Release.Title,
HTMLURL: ctx.Repo.Repository.HTMLURL() + "/releases/tag/" + item.Release.TagName,
}
}
return result
}
func toWishlistCommentV2(ctx *context.APIContext, c *wishlist_model.WishlistComment) *api.WishlistCommentV2 {
result := &api.WishlistCommentV2{
ID: c.ID,
Content: c.Content,
CreatedAt: c.CreatedUnix.AsTime(),
UpdatedAt: c.UpdatedUnix.AsTime(),
}
if c.User != nil {
result.Author = &api.BlogAuthorV2{
ID: c.User.ID,
Username: c.User.Name,
Name: c.User.FullName,
AvatarURL: c.User.AvatarLink(ctx),
}
}
return result
}
func requireWishlistEnabled(ctx *context.APIContext) {
if !ctx.Repo.Repository.WishlistEnabled {
ctx.APIErrorWithCode(apierrors.WishlistDisabled)
}
}
// ListWishlistItemsV2 lists wishlist items for a repo.
func ListWishlistItemsV2(ctx *context.APIContext) {
requireWishlistEnabled(ctx)
if ctx.Written() {
return
}
repo := ctx.Repo.Repository
page := max(ctx.FormInt("page"), 1)
limit := ctx.FormInt("limit")
if limit <= 0 || limit > 50 {
limit = 20
}
sortBy := ctx.FormString("sort")
if sortBy == "" {
sortBy = "votes"
}
statusFilter := ctx.FormString("status")
categoryID := ctx.FormInt64("category")
opts := &wishlist_model.WishlistItemSearchOptions{
RepoID: repo.ID,
CategoryID: categoryID,
SortBy: sortBy,
Page: page,
PageSize: limit,
}
if statusFilter == "closed" {
opts.AllClosed = true
} else {
opts.Status = wishlist_model.WishlistItemOpen
}
items, totalCount, err := wishlist_model.SearchWishlistItems(ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
// Load associations + batch data
itemIDs := make([]int64, 0, len(items))
for _, item := range items {
_ = item.LoadAuthor(ctx)
_ = item.LoadCategory(ctx)
_ = item.LoadRelease(ctx)
itemIDs = append(itemIDs, item.ID)
}
commentCounts, _ := wishlist_model.CountCommentsByItemIDBatch(ctx, itemIDs)
importanceSummaries, _ := wishlist_model.GetImportanceSummaryBatch(ctx, itemIDs)
// User-specific data
var userVotedItems map[int64]bool
if ctx.Doer != nil {
userVotedItems, _ = wishlist_model.GetUserVotedItemIDs(ctx, ctx.Doer.ID, repo.ID)
}
apiItems := make([]*api.WishlistItemV2, 0, len(items))
for _, item := range items {
apiItem := &api.WishlistItemV2{
ID: item.ID,
Title: item.Title,
Status: item.Status.String(),
VoteCount: item.VoteCount,
CreatedAt: item.CreatedUnix.AsTime(),
UpdatedAt: item.UpdatedUnix.AsTime(),
HTMLURL: repo.HTMLURL() + "/wishlist/" + string(rune(item.ID+'0')),
}
if item.ClosedUnix > 0 {
t := item.ClosedUnix.AsTime()
apiItem.ClosedAt = &t
}
if item.Author != nil {
apiItem.Author = &api.BlogAuthorV2{
ID: item.Author.ID,
Username: item.Author.Name,
Name: item.Author.FullName,
AvatarURL: item.Author.AvatarLink(ctx),
}
}
if item.Category != nil {
apiItem.Category = toWishlistCategoryV2(item.Category)
}
if cc, ok := commentCounts[item.ID]; ok {
apiItem.CommentCount = cc
}
if is, ok := importanceSummaries[item.ID]; ok {
apiItem.Importance = &api.WishlistImportanceV2{
NotInterested: is.NotInterested,
Important: is.Important,
Required: is.Required,
}
}
if userVotedItems != nil {
apiItem.UserVoted = userVotedItems[item.ID]
}
apiItems = append(apiItems, apiItem)
}
ctx.JSON(http.StatusOK, &api.WishlistItemListV2{
Items: apiItems,
TotalCount: totalCount,
HasMore: int64(page*limit) < totalCount,
})
}
// GetWishlistItemV2 returns a single wishlist item.
func GetWishlistItemV2(ctx *context.APIContext) {
requireWishlistEnabled(ctx)
if ctx.Written() {
return
}
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.APIErrorWithCode(apierrors.WishlistItemNotFound)
return
}
_ = item.LoadAuthor(ctx)
_ = item.LoadCategory(ctx)
_ = item.LoadRelease(ctx)
apiItem := toWishlistItemV2(ctx, item)
// Comment count
comments, _ := wishlist_model.GetCommentsByItemID(ctx, itemID)
apiItem.CommentCount = int64(len(comments))
// Importance summary
is, _ := wishlist_model.GetImportanceSummary(ctx, itemID)
if is != nil {
apiItem.Importance = &api.WishlistImportanceV2{
NotInterested: is.NotInterested,
Important: is.Important,
Required: is.Required,
}
}
// User-specific data
if ctx.Doer != nil {
voted, _ := wishlist_model.HasUserVotedItem(ctx, ctx.Doer.ID, itemID)
apiItem.UserVoted = voted
userImp, _ := wishlist_model.GetUserImportance(ctx, ctx.Doer.ID, itemID)
apiItem.UserImportance = &userImp
}
apiItem.Content = item.Content
apiItem.HTMLURL = ctx.Repo.Repository.HTMLURL() + "/wishlist/" + ctx.PathParam("id")
ctx.JSON(http.StatusOK, apiItem)
}
// CreateWishlistItemV2 creates a new wishlist item.
func CreateWishlistItemV2(ctx *context.APIContext) {
requireWishlistEnabled(ctx)
if ctx.Written() {
return
}
form := web.GetForm(ctx).(*api.CreateWishlistItemV2Option)
item := &wishlist_model.WishlistItem{
RepoID: ctx.Repo.Repository.ID,
AuthorID: ctx.Doer.ID,
Title: form.Title,
Content: form.Content,
CategoryID: form.CategoryID,
Status: wishlist_model.WishlistItemOpen,
}
if err := wishlist_model.CreateWishlistItem(ctx, item); err != nil {
ctx.APIErrorInternal(err)
return
}
_ = item.LoadAuthor(ctx)
_ = item.LoadCategory(ctx)
ctx.JSON(http.StatusCreated, toWishlistItemV2(ctx, item))
}
// WishlistVoteToggleV2 toggles a vote on a wishlist item.
func WishlistVoteToggleV2(ctx *context.APIContext) {
requireWishlistEnabled(ctx)
if ctx.Written() {
return
}
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.APIErrorWithCode(apierrors.WishlistItemNotFound)
return
}
if item.Status.IsClosed() {
ctx.JSON(http.StatusConflict, map[string]string{"error": "Cannot vote on a closed item"})
return
}
voted, err := wishlist_model.ToggleVote(ctx, ctx.Doer.ID, itemID, ctx.Repo.Repository.ID)
if err != nil {
if err.Error() == "vote budget exceeded" {
ctx.APIErrorWithCode(apierrors.WishlistVoteBudget)
return
}
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, map[string]any{
"voted": voted,
"vote_count": item.VoteCount + boolToInt(voted) - boolToInt(!voted),
})
}
// WishlistSetImportanceV2 sets the user's importance rating.
func WishlistSetImportanceV2(ctx *context.APIContext) {
requireWishlistEnabled(ctx)
if ctx.Written() {
return
}
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.APIErrorWithCode(apierrors.WishlistItemNotFound)
return
}
form := web.GetForm(ctx).(*api.SetWishlistImportanceV2Option)
if err := wishlist_model.SetImportance(ctx, ctx.Doer.ID, itemID, form.Rating); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, map[string]any{"rating": form.Rating})
}
// ListWishlistCommentsV2 lists comments for a wishlist item.
func ListWishlistCommentsV2(ctx *context.APIContext) {
requireWishlistEnabled(ctx)
if ctx.Written() {
return
}
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.APIErrorWithCode(apierrors.WishlistItemNotFound)
return
}
comments, err := wishlist_model.GetCommentsByItemID(ctx, itemID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
// Batch load reactions
commentIDs := make([]int64, 0, len(comments))
for _, c := range comments {
commentIDs = append(commentIDs, c.ID)
}
reactionCounts, _ := wishlist_model.GetCommentReactionCountsBatch(ctx, commentIDs)
apiComments := make([]*api.WishlistCommentV2, 0, len(comments))
for _, c := range comments {
ac := toWishlistCommentV2(ctx, c)
if rc, ok := reactionCounts[c.ID]; ok {
ac.Likes = rc.Likes
ac.Dislikes = rc.Dislikes
}
if ctx.Doer != nil {
userReactions, _ := wishlist_model.GetUserCommentReactionsBatch(ctx, []int64{c.ID}, ctx.Doer.ID)
if ur, ok := userReactions[c.ID]; ok {
ac.UserLiked = &ur.IsLike
}
}
apiComments = append(apiComments, ac)
}
ctx.JSON(http.StatusOK, apiComments)
}
// CreateWishlistCommentV2 creates a comment on a wishlist item.
func CreateWishlistCommentV2(ctx *context.APIContext) {
requireWishlistEnabled(ctx)
if ctx.Written() {
return
}
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.APIErrorWithCode(apierrors.WishlistItemNotFound)
return
}
form := web.GetForm(ctx).(*api.CreateWishlistCommentV2Option)
comment := &wishlist_model.WishlistComment{
ItemID: itemID,
UserID: ctx.Doer.ID,
Content: form.Content,
}
if err := wishlist_model.CreateComment(ctx, comment); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusCreated, toWishlistCommentV2(ctx, comment))
}
// CloseWishlistItemV2 closes a wishlist item (admin only).
func CloseWishlistItemV2(ctx *context.APIContext) {
requireWishlistEnabled(ctx)
if ctx.Written() {
return
}
if !ctx.Repo.IsAdmin() {
ctx.JSON(http.StatusForbidden, map[string]string{"error": "Admin access required"})
return
}
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.APIErrorWithCode(apierrors.WishlistItemNotFound)
return
}
form := web.GetForm(ctx).(*api.CloseWishlistItemV2Option)
var status wishlist_model.WishlistItemStatus
switch form.Status {
case "completed":
status = wishlist_model.WishlistItemCompleted
case "will_not_do":
status = wishlist_model.WishlistItemWillNotDo
default:
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid status. Must be 'completed' or 'will_not_do'"})
return
}
if err := wishlist_model.CloseWishlistItem(ctx, itemID, status, form.ReleaseID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, map[string]string{"status": form.Status})
}
// ReopenWishlistItemV2 reopens a closed wishlist item (admin only).
func ReopenWishlistItemV2(ctx *context.APIContext) {
requireWishlistEnabled(ctx)
if ctx.Written() {
return
}
if !ctx.Repo.IsAdmin() {
ctx.JSON(http.StatusForbidden, map[string]string{"error": "Admin access required"})
return
}
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.APIErrorWithCode(apierrors.WishlistItemNotFound)
return
}
if err := wishlist_model.ReopenWishlistItem(ctx, itemID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, map[string]string{"status": "open"})
}
// ListWishlistCategoriesV2 lists categories for a repo.
func ListWishlistCategoriesV2(ctx *context.APIContext) {
requireWishlistEnabled(ctx)
if ctx.Written() {
return
}
cats, err := wishlist_model.GetCategoriesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
apiCats := make([]*api.WishlistCategoryV2, 0, len(cats))
for _, c := range cats {
apiCats = append(apiCats, toWishlistCategoryV2(c))
}
ctx.JSON(http.StatusOK, apiCats)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

View File

@@ -0,0 +1,109 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"net/http"
repo_model "code.gitcaddy.com/server/v3/models/repo"
wishlist_model "code.gitcaddy.com/server/v3/models/wishlist"
"code.gitcaddy.com/server/v3/modules/templates"
"code.gitcaddy.com/server/v3/modules/web"
"code.gitcaddy.com/server/v3/services/context"
"code.gitcaddy.com/server/v3/services/forms"
)
const tplWishlist templates.TplName = "repo/settings/wishlist"
// WishlistSettings shows the wishlist settings page.
func WishlistSettings(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.wishlist")
ctx.Data["PageIsSettingsWishlist"] = true
cats, err := wishlist_model.GetCategoriesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetCategoriesByRepoID", err)
return
}
ctx.Data["Categories"] = cats
ctx.HTML(http.StatusOK, tplWishlist)
}
// WishlistSettingsPost toggles the wishlist enabled flag.
func WishlistSettingsPost(ctx *context.Context) {
repo := ctx.Repo.Repository
repo.WishlistEnabled = ctx.FormBool("wishlist_enabled")
if err := repo_model.UpdateRepositoryColsWithAutoTime(ctx, repo, "wishlist_enabled"); err != nil {
ctx.ServerError("UpdateRepositoryCols", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.wishlist.saved"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings/wishlist")
}
// WishlistCategoryPost creates a new category.
func WishlistCategoryPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.WishlistCategoryForm)
cat := &wishlist_model.WishlistCategory{
RepoID: ctx.Repo.Repository.ID,
Name: form.Name,
Color: form.Color,
SortOrder: form.SortOrder,
}
if err := wishlist_model.CreateCategory(ctx, cat); err != nil {
ctx.ServerError("CreateCategory", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.wishlist.category_created"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings/wishlist")
}
// WishlistCategoryEdit updates an existing category.
func WishlistCategoryEdit(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.WishlistCategoryForm)
id := ctx.PathParamInt64("id")
cat, err := wishlist_model.GetCategoryByID(ctx, id)
if err != nil {
ctx.NotFound(err)
return
}
if cat.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
cat.Name = form.Name
cat.Color = form.Color
cat.SortOrder = form.SortOrder
if err := wishlist_model.UpdateCategory(ctx, cat); err != nil {
ctx.ServerError("UpdateCategory", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.wishlist.category_saved"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings/wishlist")
}
// WishlistCategoryDelete removes a category.
func WishlistCategoryDelete(ctx *context.Context) {
id := ctx.PathParamInt64("id")
cat, err := wishlist_model.GetCategoryByID(ctx, id)
if err != nil {
ctx.NotFound(err)
return
}
if cat.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
if err := wishlist_model.DeleteCategory(ctx, id); err != nil {
ctx.ServerError("DeleteCategory", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.wishlist.category_deleted"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings/wishlist")
}

View File

@@ -0,0 +1,437 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"fmt"
"net/http"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/models/renderhelper"
repo_model "code.gitcaddy.com/server/v3/models/repo"
wishlist_model "code.gitcaddy.com/server/v3/models/wishlist"
"code.gitcaddy.com/server/v3/modules/markup/markdown"
"code.gitcaddy.com/server/v3/modules/templates"
"code.gitcaddy.com/server/v3/modules/web"
"code.gitcaddy.com/server/v3/services/context"
"code.gitcaddy.com/server/v3/services/forms"
)
const (
tplWishlistList templates.TplName = "repo/wishlist/list"
tplWishlistView templates.TplName = "repo/wishlist/view"
tplWishlistNew templates.TplName = "repo/wishlist/new"
)
// WishlistList shows the split-screen wishlist listing.
func WishlistList(ctx *context.Context) {
if !ctx.Repo.Repository.WishlistEnabled {
ctx.NotFound(nil)
return
}
ctx.Data["Title"] = ctx.Tr("repo.wishlist")
ctx.Data["PageIsRepoWishlist"] = true
page := max(ctx.FormInt("page"), 1)
sortBy := ctx.FormString("sort")
if sortBy == "" {
sortBy = "votes"
}
statusFilter := ctx.FormString("status")
categoryID := ctx.FormInt64("category")
opts := &wishlist_model.WishlistItemSearchOptions{
RepoID: ctx.Repo.Repository.ID,
CategoryID: categoryID,
SortBy: sortBy,
Page: page,
PageSize: 20,
}
if statusFilter == "closed" {
opts.AllClosed = true
} else {
opts.Status = wishlist_model.WishlistItemOpen
}
items, totalCount, err := wishlist_model.SearchWishlistItems(ctx, opts)
if err != nil {
ctx.ServerError("SearchWishlistItems", err)
return
}
// Load authors and categories
for _, item := range items {
_ = item.LoadAuthor(ctx)
_ = item.LoadCategory(ctx)
}
// Batch load comment counts
itemIDs := make([]int64, 0, len(items))
for _, item := range items {
itemIDs = append(itemIDs, item.ID)
}
commentCounts, _ := wishlist_model.CountCommentsByItemIDBatch(ctx, itemIDs)
ctx.Data["CommentCounts"] = commentCounts
// Batch load importance summaries
importanceSummaries, _ := wishlist_model.GetImportanceSummaryBatch(ctx, itemIDs)
ctx.Data["ImportanceSummaries"] = importanceSummaries
// Load user-specific data
if ctx.Doer != nil {
votedItems, _ := wishlist_model.GetUserVotedItemIDs(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
ctx.Data["UserVotedItems"] = votedItems
activeVotes, _ := wishlist_model.CountUserActiveVotesInRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
ctx.Data["UserActiveVotes"] = activeVotes
ctx.Data["MaxVotes"] = wishlist_model.MaxVotesPerRepo
ctx.Data["RemainingVotes"] = wishlist_model.MaxVotesPerRepo - int(activeVotes)
}
// Load categories for filter sidebar
categories, err := wishlist_model.GetCategoriesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetCategoriesByRepoID", err)
return
}
ctx.Data["Items"] = items
ctx.Data["TotalCount"] = totalCount
ctx.Data["Categories"] = categories
ctx.Data["SortBy"] = sortBy
ctx.Data["StatusFilter"] = statusFilter
ctx.Data["CategoryFilter"] = categoryID
ctx.Data["Page"] = page
ctx.Data["PageCount"] = (totalCount + 19) / 20
ctx.HTML(http.StatusOK, tplWishlistList)
}
// WishlistView shows a single wishlist item with comments.
func WishlistView(ctx *context.Context) {
if !ctx.Repo.Repository.WishlistEnabled {
ctx.NotFound(nil)
return
}
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil {
ctx.NotFound(err)
return
}
if item.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
_ = item.LoadAuthor(ctx)
_ = item.LoadCategory(ctx)
_ = item.LoadRelease(ctx)
// Render markdown content
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository)
rendered, err := markdown.RenderString(rctx, item.Content)
if err == nil {
item.RenderedContent = string(rendered)
}
// Load comments
comments, err := wishlist_model.GetCommentsByItemID(ctx, itemID)
if err != nil {
ctx.ServerError("GetCommentsByItemID", err)
return
}
// Batch load comment reactions
commentIDs := make([]int64, 0, len(comments))
for _, c := range comments {
commentIDs = append(commentIDs, c.ID)
}
reactionCounts, _ := wishlist_model.GetCommentReactionCountsBatch(ctx, commentIDs)
ctx.Data["ReactionCounts"] = reactionCounts
// Importance summary
importanceSummary, _ := wishlist_model.GetImportanceSummary(ctx, itemID)
ctx.Data["ImportanceSummary"] = importanceSummary
// User-specific data
if ctx.Doer != nil {
hasVoted, _ := wishlist_model.HasUserVotedItem(ctx, ctx.Doer.ID, itemID)
ctx.Data["UserVoted"] = hasVoted
activeVotes, _ := wishlist_model.CountUserActiveVotesInRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
ctx.Data["UserActiveVotes"] = activeVotes
ctx.Data["RemainingVotes"] = wishlist_model.MaxVotesPerRepo - int(activeVotes)
userImportance, _ := wishlist_model.GetUserImportance(ctx, ctx.Doer.ID, itemID)
ctx.Data["UserImportance"] = userImportance
userReactions, _ := wishlist_model.GetUserCommentReactionsBatch(ctx, commentIDs, ctx.Doer.ID)
ctx.Data["UserReactions"] = userReactions
}
// Load releases for admin close dialog
isAdmin := ctx.Repo.IsAdmin()
ctx.Data["IsRepoAdmin"] = isAdmin
if isAdmin {
releases, err := db.Find[repo_model.Release](ctx, &repo_model.FindReleasesOptions{
RepoID: ctx.Repo.Repository.ID,
IncludeDrafts: false,
IncludeTags: false,
})
if err == nil {
ctx.Data["Releases"] = releases
}
}
ctx.Data["Title"] = item.Title
ctx.Data["PageIsRepoWishlist"] = true
ctx.Data["Item"] = item
ctx.Data["Comments"] = comments
ctx.Data["MaxVotes"] = wishlist_model.MaxVotesPerRepo
ctx.HTML(http.StatusOK, tplWishlistView)
}
// WishlistNew shows the new item form.
func WishlistNew(ctx *context.Context) {
if !ctx.Repo.Repository.WishlistEnabled {
ctx.NotFound(nil)
return
}
ctx.Data["Title"] = ctx.Tr("repo.wishlist.new_item")
ctx.Data["PageIsRepoWishlist"] = true
categories, _ := wishlist_model.GetCategoriesByRepoID(ctx, ctx.Repo.Repository.ID)
ctx.Data["Categories"] = categories
ctx.HTML(http.StatusOK, tplWishlistNew)
}
// WishlistNewPost creates a new wishlist item.
func WishlistNewPost(ctx *context.Context) {
if !ctx.Repo.Repository.WishlistEnabled {
ctx.NotFound(nil)
return
}
form := web.GetForm(ctx).(*forms.WishlistItemForm)
if ctx.HasError() {
ctx.Data["Title"] = ctx.Tr("repo.wishlist.new_item")
ctx.Data["PageIsRepoWishlist"] = true
categories, _ := wishlist_model.GetCategoriesByRepoID(ctx, ctx.Repo.Repository.ID)
ctx.Data["Categories"] = categories
ctx.HTML(http.StatusOK, tplWishlistNew)
return
}
item := &wishlist_model.WishlistItem{
RepoID: ctx.Repo.Repository.ID,
AuthorID: ctx.Doer.ID,
Title: form.Title,
Content: form.Content,
CategoryID: form.CategoryID,
Status: wishlist_model.WishlistItemOpen,
}
if err := wishlist_model.CreateWishlistItem(ctx, item); err != nil {
ctx.ServerError("CreateWishlistItem", err)
return
}
ctx.Redirect(fmt.Sprintf("%s/wishlist/%d", ctx.Repo.RepoLink, item.ID))
}
// WishlistVoteToggle toggles a vote on an item.
func WishlistVoteToggle(ctx *context.Context) {
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
if item.Status.IsClosed() {
ctx.Flash.Error(ctx.Tr("repo.wishlist.cannot_vote_closed"))
ctx.Redirect(fmt.Sprintf("%s/wishlist/%d", ctx.Repo.RepoLink, itemID))
return
}
_, err = wishlist_model.ToggleVote(ctx, ctx.Doer.ID, itemID, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("ToggleVote", err)
return
}
ctx.Redirect(fmt.Sprintf("%s/wishlist/%d", ctx.Repo.RepoLink, itemID))
}
// WishlistSetImportance sets the user's importance rating.
func WishlistSetImportance(ctx *context.Context) {
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
rating := ctx.FormInt("rating")
if rating < 0 || rating > 2 {
rating = 0
}
if err := wishlist_model.SetImportance(ctx, ctx.Doer.ID, itemID, rating); err != nil {
ctx.ServerError("SetImportance", err)
return
}
ctx.Redirect(fmt.Sprintf("%s/wishlist/%d", ctx.Repo.RepoLink, itemID))
}
// WishlistCommentPost adds a comment to an item.
func WishlistCommentPost(ctx *context.Context) {
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
form := web.GetForm(ctx).(*forms.WishlistCommentForm)
if ctx.HasError() {
ctx.Redirect(fmt.Sprintf("%s/wishlist/%d", ctx.Repo.RepoLink, itemID))
return
}
comment := &wishlist_model.WishlistComment{
ItemID: itemID,
UserID: ctx.Doer.ID,
Content: form.Content,
}
if err := wishlist_model.CreateComment(ctx, comment); err != nil {
ctx.ServerError("CreateComment", err)
return
}
ctx.Redirect(fmt.Sprintf("%s/wishlist/%d#comment-%d", ctx.Repo.RepoLink, itemID, comment.ID))
}
// WishlistCommentReact toggles a reaction on a comment.
func WishlistCommentReact(ctx *context.Context) {
commentID := ctx.PathParamInt64("commentID")
reactionType := ctx.FormString("type")
var isLike bool
switch reactionType {
case "like":
isLike = true
case "dislike":
isLike = false
default:
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid reaction type"})
return
}
reacted, err := wishlist_model.ToggleCommentReaction(ctx, commentID, ctx.Doer.ID, isLike)
if err != nil {
ctx.ServerError("ToggleCommentReaction", err)
return
}
counts, err := wishlist_model.GetCommentReactionCounts(ctx, commentID)
if err != nil {
ctx.ServerError("GetCommentReactionCounts", err)
return
}
ctx.JSON(http.StatusOK, map[string]any{
"reacted": reacted,
"type": reactionType,
"likes": counts.Likes,
"dislikes": counts.Dislikes,
})
}
// WishlistCommentDelete deletes a comment (admin only).
func WishlistCommentDelete(ctx *context.Context) {
itemID := ctx.PathParamInt64("id")
commentID := ctx.PathParamInt64("commentID")
comment, err := wishlist_model.GetCommentByID(ctx, commentID)
if err != nil {
ctx.NotFound(nil)
return
}
// Verify comment belongs to an item in this repo
item, err := wishlist_model.GetWishlistItemByID(ctx, comment.ItemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
if err := wishlist_model.DeleteComment(ctx, commentID); err != nil {
ctx.ServerError("DeleteComment", err)
return
}
ctx.Redirect(fmt.Sprintf("%s/wishlist/%d", ctx.Repo.RepoLink, itemID))
}
// WishlistClose closes an item as completed or will-not-do (admin only).
func WishlistClose(ctx *context.Context) {
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
statusStr := ctx.FormString("status")
var status wishlist_model.WishlistItemStatus
switch statusStr {
case "completed":
status = wishlist_model.WishlistItemCompleted
case "will_not_do":
status = wishlist_model.WishlistItemWillNotDo
default:
ctx.Flash.Error("Invalid status")
ctx.Redirect(fmt.Sprintf("%s/wishlist/%d", ctx.Repo.RepoLink, itemID))
return
}
releaseID := ctx.FormInt64("release_id")
if err := wishlist_model.CloseWishlistItem(ctx, itemID, status, releaseID); err != nil {
ctx.ServerError("CloseWishlistItem", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.wishlist.item_closed"))
ctx.Redirect(fmt.Sprintf("%s/wishlist/%d", ctx.Repo.RepoLink, itemID))
}
// WishlistReopen reopens a closed item (admin only).
func WishlistReopen(ctx *context.Context) {
itemID := ctx.PathParamInt64("id")
item, err := wishlist_model.GetWishlistItemByID(ctx, itemID)
if err != nil || item.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
if err := wishlist_model.ReopenWishlistItem(ctx, itemID); err != nil {
ctx.ServerError("ReopenWishlistItem", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.wishlist.item_reopened"))
ctx.Redirect(fmt.Sprintf("%s/wishlist/%d", ctx.Repo.RepoLink, itemID))
}

View File

@@ -1348,6 +1348,13 @@ func registerWebRoutes(m *web.Router) {
m.Post("/products/delete", repo_setting.SubscriptionsProductDelete)
m.Get("/clients", repo_setting.SubscriptionsClients)
})
m.Group("/wishlist", func() {
m.Get("", repo_setting.WishlistSettings)
m.Post("", repo_setting.WishlistSettingsPost)
m.Post("/categories", web.Bind(forms.WishlistCategoryForm{}), repo_setting.WishlistCategoryPost)
m.Post("/categories/{id}/edit", web.Bind(forms.WishlistCategoryForm{}), repo_setting.WishlistCategoryEdit)
m.Post("/categories/{id}/delete", repo_setting.WishlistCategoryDelete)
})
// the follow handler must be under "settings", otherwise this incomplete repo can't be accessed
m.Group("/migrate", func() {
m.Post("/retry", repo.MigrateRetryPost)
@@ -1742,6 +1749,27 @@ func registerWebRoutes(m *web.Router) {
})
// end "/{username}/{reponame}/blog"
m.Group("/{username}/{reponame}/wishlist", func() {
m.Get("", repo.WishlistList)
m.Get("/new", reqSignIn, repo.WishlistNew)
m.Post("/new", reqSignIn, web.Bind(forms.WishlistItemForm{}), repo.WishlistNewPost)
m.Get("/{id}", repo.WishlistView)
m.Post("/{id}/vote", reqSignIn, repo.WishlistVoteToggle)
m.Post("/{id}/importance", reqSignIn, repo.WishlistSetImportance)
m.Post("/{id}/comment", reqSignIn, web.Bind(forms.WishlistCommentForm{}), repo.WishlistCommentPost)
m.Post("/{id}/comment/{commentID}/react", reqSignIn, repo.WishlistCommentReact)
m.Post("/{id}/comment/{commentID}/delete", reqSignIn, reqRepoAdmin, repo.WishlistCommentDelete)
m.Post("/{id}/close", reqSignIn, reqRepoAdmin, repo.WishlistClose)
m.Post("/{id}/reopen", reqSignIn, reqRepoAdmin, repo.WishlistReopen)
}, optSignIn, context.RepoAssignment, func(ctx *context.Context) {
if !ctx.Repo.Repository.WishlistEnabled {
ctx.NotFound(nil)
return
}
ctx.Data["PageIsRepoWishlist"] = true
})
// end "/{username}/{reponame}/wishlist"
m.Group("/{username}/{reponame}/pages", func() {
m.Get("", pages.ServeRepoLandingPage)
m.Get("/assets/*", pages.ServeRepoPageAsset)

View File

@@ -16,6 +16,7 @@ import (
asymkey_model "code.gitcaddy.com/server/v3/models/asymkey"
blog_model "code.gitcaddy.com/server/v3/models/blog"
wishlist_model "code.gitcaddy.com/server/v3/models/wishlist"
"code.gitcaddy.com/server/v3/models/db"
git_model "code.gitcaddy.com/server/v3/models/git"
issues_model "code.gitcaddy.com/server/v3/models/issues"
@@ -569,6 +570,16 @@ func RepoAssignment(ctx *Context) {
ctx.Data["BlogPostCount"] = blogCount
}
// Wishlist open count for repo header tab
if repo.WishlistEnabled {
wishlistCount, err := wishlist_model.CountOpenWishlistItems(ctx, repo.ID)
if err != nil {
ctx.ServerError("CountOpenWishlistItems", err)
return
}
ctx.Data["WishlistOpenCount"] = wishlistCount
}
ctx.Data["Title"] = repo.Owner.Name + "/" + repo.Name
ctx.Data["PageTitleCommon"] = repo.Name + " - " + setting.AppName
ctx.Data["Repository"] = repo

View File

@@ -0,0 +1,50 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package forms
import (
"net/http"
"code.gitcaddy.com/server/v3/modules/web/middleware"
"code.gitcaddy.com/server/v3/services/context"
"gitea.com/go-chi/binding"
)
// WishlistItemForm is the form for creating wishlist items.
type WishlistItemForm struct {
Title string `binding:"Required;MaxSize(255)"`
Content string `binding:"Required"`
CategoryID int64
}
// Validate validates the fields.
func (f *WishlistItemForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// WishlistCommentForm is the form for adding comments.
type WishlistCommentForm struct {
Content string `binding:"Required"`
}
// Validate validates the fields.
func (f *WishlistCommentForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// WishlistCategoryForm is the form for managing categories.
type WishlistCategoryForm struct {
Name string `binding:"Required;MaxSize(100)"`
Color string `binding:"Required;MaxSize(7)"`
SortOrder int
}
// Validate validates the fields.
func (f *WishlistCategoryForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}

View File

@@ -222,6 +222,13 @@
{{end}}
{{end}}
{{if .Repository.WishlistEnabled}}
<a class="{{if .PageIsRepoWishlist}}active {{end}}item" href="{{.RepoLink}}/wishlist">
{{svg "octicon-light-bulb"}} {{ctx.Locale.Tr "repo.wishlist"}}
{{if gt .WishlistOpenCount 0}}<span class="ui small label">{{.WishlistOpenCount}}</span>{{end}}
</a>
{{end}}
{{if .Permission.CanRead ctx.Consts.RepoUnitTypeExternalWiki}}
<a class="item" href="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalWiki).ExternalWikiConfig.ExternalWikiURL}}" target="_blank" rel="noopener noreferrer">
{{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.wiki"}}

View File

@@ -117,5 +117,8 @@
</div>
</details>
{{end}}
<a class="{{if .PageIsSettingsWishlist}}active {{end}}item" href="{{.RepoLink}}/settings/wishlist">
{{ctx.Locale.Tr "repo.settings.wishlist"}}
</a>
</div>
</div>

View File

@@ -0,0 +1,84 @@
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings wishlist")}}
<div class="repo-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.wishlist"}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post" action="{{.RepoLink}}/settings/wishlist">
{{.CsrfTokenHtml}}
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="wishlist_enabled" {{if .Repository.WishlistEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.wishlist.enable"}}</label>
</div>
</div>
<p class="help">{{ctx.Locale.Tr "repo.settings.wishlist.enable_help"}}</p>
<div class="field">
<button class="ui primary button">{{svg "octicon-check" 16}} {{ctx.Locale.Tr "save"}}</button>
</div>
</form>
</div>
{{if .Repository.WishlistEnabled}}
<h4 class="ui top attached header tw-mt-4">
{{ctx.Locale.Tr "repo.settings.wishlist.categories"}}
</h4>
<div class="ui attached segment">
{{if .Categories}}
<table class="ui very basic table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.settings.wishlist.category_name"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.wishlist.category_color"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.wishlist.category_sort"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Categories}}
<tr>
<form class="ui form" method="post" action="{{$.RepoLink}}/settings/wishlist/categories/{{.ID}}/edit">
{{$.CsrfTokenHtml}}
<td><input type="text" name="name" value="{{.Name}}" required maxlength="100"></td>
<td><input type="color" name="color" value="{{.Color}}" style="width: 50px; height: 32px; padding: 2px;"></td>
<td><input type="number" name="sort_order" value="{{.SortOrder}}" style="width: 80px;"></td>
<td class="tw-text-right">
<button class="ui primary mini button" type="submit">{{ctx.Locale.Tr "save"}}</button>
</form>
<form method="post" action="{{$.RepoLink}}/settings/wishlist/categories/{{.ID}}/delete" style="display:inline;">
{{$.CsrfTokenHtml}}
<button class="ui red mini button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "repo.settings.wishlist.category_delete_confirm"}}');">{{ctx.Locale.Tr "delete"}}</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="tw-text-center tw-py-4">{{ctx.Locale.Tr "repo.settings.wishlist.no_categories"}}</p>
{{end}}
<div class="divider"></div>
<h5>{{ctx.Locale.Tr "repo.settings.wishlist.add_category"}}</h5>
<form class="ui form" method="post" action="{{.RepoLink}}/settings/wishlist/categories">
{{.CsrfTokenHtml}}
<div class="three fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.wishlist.category_name"}}</label>
<input type="text" name="name" required maxlength="100" placeholder="{{ctx.Locale.Tr "repo.settings.wishlist.category_name_placeholder"}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.wishlist.category_color"}}</label>
<input type="color" name="color" value="#6c757d" style="width: 50px; height: 32px; padding: 2px;">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.wishlist.category_sort"}}</label>
<input type="number" name="sort_order" value="0" style="width: 80px;">
</div>
</div>
<button class="ui primary button" type="submit">{{svg "octicon-plus" 16}} {{ctx.Locale.Tr "repo.settings.wishlist.add_category"}}</button>
</form>
</div>
{{end}}
</div>
{{template "repo/settings/layout_footer" .}}

View File

@@ -0,0 +1,351 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wishlist">
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
<div class="wishlist-toolbar">
<div class="wishlist-toolbar-left">
<div class="ui small compact menu">
<a class="item{{if eq .SortBy "votes"}} active{{end}}" href="{{.RepoLink}}/wishlist?sort=votes&status={{.StatusFilter}}&category={{.CategoryFilter}}">
{{svg "octicon-thumbsup" 14}} {{ctx.Locale.Tr "repo.wishlist.sort_votes"}}
</a>
<a class="item{{if eq .SortBy "newest"}} active{{end}}" href="{{.RepoLink}}/wishlist?sort=newest&status={{.StatusFilter}}&category={{.CategoryFilter}}">
{{svg "octicon-clock" 14}} {{ctx.Locale.Tr "repo.wishlist.sort_newest"}}
</a>
<a class="item{{if eq .SortBy "importance"}} active{{end}}" href="{{.RepoLink}}/wishlist?sort=importance&status={{.StatusFilter}}&category={{.CategoryFilter}}">
{{svg "octicon-flame" 14}} {{ctx.Locale.Tr "repo.wishlist.sort_importance"}}
</a>
</div>
</div>
{{if .IsSigned}}
<div class="wishlist-toolbar-right">
<a class="ui primary small button" href="{{.RepoLink}}/wishlist/new">
{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.wishlist.new_item"}}
</a>
</div>
{{end}}
</div>
<div class="wishlist-split-pane">
<div class="wishlist-content-pane">
{{if .Items}}
<div class="wishlist-item-list">
{{range .Items}}
<div class="wishlist-item-card{{if .Status.IsClosed}} wishlist-item-closed{{end}}">
<div class="wishlist-item-vote">
<div class="wishlist-vote-count{{if and $.UserVotedItems (index $.UserVotedItems .ID)}} voted{{end}}">
{{svg "octicon-triangle-up" 18}}
<span>{{.VoteCount}}</span>
</div>
</div>
<div class="wishlist-item-main">
<a class="wishlist-item-title" href="{{$.RepoLink}}/wishlist/{{.ID}}">{{.Title}}</a>
<div class="wishlist-item-meta">
{{if .Category}}
<span class="wishlist-category-badge" style="background-color: {{.Category.Color}}20; color: {{.Category.Color}}; border: 1px solid {{.Category.Color}}40;">{{.Category.Name}}</span>
{{end}}
{{if .Status.IsClosed}}
<span class="ui mini label {{if eq .Status.String "completed"}}green{{else}}red{{end}}">{{ctx.Locale.Tr (printf "repo.wishlist.status_%s" .Status.String)}}</span>
{{end}}
{{if .Author}}
<span class="wishlist-meta-text">{{.Author.DisplayName}}</span>
<span class="wishlist-meta-sep">&middot;</span>
{{end}}
<span class="wishlist-meta-text">{{DateUtils.TimeSince .CreatedUnix}}</span>
{{if and $.CommentCounts (index $.CommentCounts .ID)}}
<span class="wishlist-meta-sep">&middot;</span>
<span class="wishlist-meta-text">{{svg "octicon-comment" 12}} {{index $.CommentCounts .ID}}</span>
{{end}}
{{if and $.ImportanceSummaries (index $.ImportanceSummaries .ID)}}
{{with index $.ImportanceSummaries .ID}}
{{if or (gt .Important 0) (gt .Required 0)}}
<span class="wishlist-meta-sep">&middot;</span>
<span class="wishlist-meta-text wishlist-importance-hint">
{{if gt .Required 0}}{{svg "octicon-flame" 12}} {{.Required}}{{end}}
{{if gt .Important 0}}{{svg "octicon-star" 12}} {{.Important}}{{end}}
</span>
{{end}}
{{end}}
{{end}}
</div>
</div>
</div>
{{end}}
</div>
{{if gt .PageCount 1}}
<div class="wishlist-pagination">
{{if gt .Page 1}}
<a class="ui small button" href="{{.RepoLink}}/wishlist?page={{Eval .Page "-" 1}}&sort={{.SortBy}}&status={{.StatusFilter}}&category={{.CategoryFilter}}">{{ctx.Locale.Tr "repo.wishlist.prev"}}</a>
{{end}}
<span class="wishlist-page-info">{{ctx.Locale.Tr "repo.wishlist.page_info" .Page .PageCount}}</span>
{{if lt .Page .PageCount}}
<a class="ui small button" href="{{.RepoLink}}/wishlist?page={{Eval .Page "+" 1}}&sort={{.SortBy}}&status={{.StatusFilter}}&category={{.CategoryFilter}}">{{ctx.Locale.Tr "repo.wishlist.next"}}</a>
{{end}}
</div>
{{end}}
{{else}}
<div class="empty-placeholder">
{{svg "octicon-light-bulb" 48}}
<h2>{{ctx.Locale.Tr "repo.wishlist.no_items"}}</h2>
{{if .IsSigned}}
<p>{{ctx.Locale.Tr "repo.wishlist.no_items_hint"}}</p>
{{end}}
</div>
{{end}}
</div>
<div class="wishlist-sidebar-pane">
{{if .IsSigned}}
<div class="wishlist-sidebar-section">
<h5>{{ctx.Locale.Tr "repo.wishlist.your_votes"}}</h5>
<div class="wishlist-vote-budget">
<span class="wishlist-vote-budget-count">{{.RemainingVotes}}/{{.MaxVotes}}</span>
<span>{{ctx.Locale.Tr "repo.wishlist.votes_remaining"}}</span>
</div>
</div>
{{end}}
<div class="wishlist-sidebar-section">
<h5>{{ctx.Locale.Tr "repo.wishlist.status"}}</h5>
<div class="wishlist-status-filter">
<a class="wishlist-filter-btn{{if ne .StatusFilter "closed"}} active{{end}}" href="{{.RepoLink}}/wishlist?sort={{.SortBy}}&category={{.CategoryFilter}}">
{{ctx.Locale.Tr "repo.wishlist.filter_open"}} ({{if ne .StatusFilter "closed"}}{{.TotalCount}}{{else}}&mdash;{{end}})
</a>
<a class="wishlist-filter-btn{{if eq .StatusFilter "closed"}} active{{end}}" href="{{.RepoLink}}/wishlist?sort={{.SortBy}}&status=closed&category={{.CategoryFilter}}">
{{ctx.Locale.Tr "repo.wishlist.filter_closed"}} ({{if eq .StatusFilter "closed"}}{{.TotalCount}}{{else}}&mdash;{{end}})
</a>
</div>
</div>
{{if .Categories}}
<div class="wishlist-sidebar-section">
<h5>{{ctx.Locale.Tr "repo.wishlist.categories"}}</h5>
<div class="wishlist-category-filter">
<a class="wishlist-filter-btn{{if eq .CategoryFilter 0}} active{{end}}" href="{{.RepoLink}}/wishlist?sort={{.SortBy}}&status={{.StatusFilter}}">
{{ctx.Locale.Tr "repo.wishlist.all_categories"}}
</a>
{{range .Categories}}
<a class="wishlist-filter-btn{{if eq $.CategoryFilter .ID}} active{{end}}" href="{{$.RepoLink}}/wishlist?sort={{$.SortBy}}&status={{$.StatusFilter}}&category={{.ID}}">
<span class="wishlist-category-dot" style="background-color: {{.Color}};"></span>
{{.Name}}
</a>
{{end}}
</div>
</div>
{{end}}
</div>
</div>
</div>
</div>
<style>
.wishlist-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 8px;
}
.wishlist-split-pane {
display: flex;
gap: 24px;
}
.wishlist-content-pane {
flex: 1;
min-width: 0;
}
.wishlist-sidebar-pane {
width: 240px;
flex-shrink: 0;
}
.wishlist-sidebar-section {
margin-bottom: 20px;
}
.wishlist-sidebar-section h5 {
margin: 0 0 8px 0;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-light);
}
.wishlist-vote-budget {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--color-text-light);
}
.wishlist-vote-budget-count {
font-weight: 600;
color: var(--color-primary);
font-size: 15px;
}
.wishlist-status-filter,
.wishlist-category-filter {
display: flex;
flex-direction: column;
gap: 2px;
}
.wishlist-filter-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
color: var(--color-text);
text-decoration: none;
transition: background 0.15s;
}
.wishlist-filter-btn:hover {
background: var(--color-secondary-alpha-20);
color: var(--color-text);
}
.wishlist-filter-btn.active {
background: var(--color-primary-alpha-20);
color: var(--color-primary);
font-weight: 600;
}
.wishlist-category-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.wishlist-item-list {
display: flex;
flex-direction: column;
gap: 1px;
border: 1px solid var(--color-secondary);
border-radius: 6px;
overflow: hidden;
}
.wishlist-item-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
background: var(--color-body);
transition: background 0.1s;
}
.wishlist-item-card:hover {
background: var(--color-hover);
}
.wishlist-item-closed {
opacity: 0.7;
}
.wishlist-item-vote {
flex-shrink: 0;
text-align: center;
min-width: 48px;
}
.wishlist-vote-count {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--color-secondary);
background: var(--color-body);
color: var(--color-text-light);
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.wishlist-vote-count.voted {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-primary-alpha-10, rgba(var(--color-primary-rgb), 0.1));
}
.wishlist-vote-count .svg {
opacity: 0.5;
}
.wishlist-vote-count.voted .svg {
opacity: 1;
}
.wishlist-item-main {
flex: 1;
min-width: 0;
}
.wishlist-item-title {
font-size: 15px;
font-weight: 600;
color: var(--color-text);
text-decoration: none;
line-height: 1.3;
}
.wishlist-item-title:hover {
color: var(--color-primary);
}
.wishlist-item-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
font-size: 12px;
color: var(--color-text-light);
}
.wishlist-category-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
.wishlist-meta-sep {
color: var(--color-text-light-3);
}
.wishlist-importance-hint {
display: inline-flex;
align-items: center;
gap: 4px;
}
.wishlist-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 16px;
padding: 12px 0;
}
.wishlist-page-info {
font-size: 13px;
color: var(--color-text-light);
}
.empty-placeholder {
text-align: center;
padding: 60px 20px;
color: var(--color-text-light);
}
.empty-placeholder .svg {
opacity: 0.3;
margin-bottom: 16px;
}
.empty-placeholder h2 {
font-size: 18px;
margin: 0 0 8px 0;
}
@media (max-width: 768px) {
.wishlist-split-pane {
flex-direction: column-reverse;
}
.wishlist-sidebar-pane {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.wishlist-sidebar-section {
flex: 1;
min-width: 150px;
margin-bottom: 0;
}
}
</style>
{{template "base/footer" .}}

View File

@@ -0,0 +1,40 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wishlist">
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
<h2 class="ui header">
{{ctx.Locale.Tr "repo.wishlist.new_item"}}
</h2>
<form class="ui form" method="post" action="{{.RepoLink}}/wishlist/new">
{{.CsrfTokenHtml}}
<div class="field {{if .Err_Title}}error{{end}}">
<label>{{ctx.Locale.Tr "repo.wishlist.title"}}</label>
<input type="text" name="title" required maxlength="255" placeholder="{{ctx.Locale.Tr "repo.wishlist.title_placeholder"}}" value="{{.title}}">
</div>
<div class="field {{if .Err_Content}}error{{end}}">
<label>{{ctx.Locale.Tr "repo.wishlist.content"}}</label>
<textarea name="content" rows="8" placeholder="{{ctx.Locale.Tr "repo.wishlist.content_placeholder"}}">{{.content}}</textarea>
<p class="help">{{ctx.Locale.Tr "repo.wishlist.content_help"}}</p>
</div>
{{if .Categories}}
<div class="field">
<label>{{ctx.Locale.Tr "repo.wishlist.category"}}</label>
<select name="category_id" class="ui dropdown">
<option value="0">{{ctx.Locale.Tr "repo.wishlist.no_category"}}</option>
{{range .Categories}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
{{end}}
<div class="field">
<button class="ui primary button" type="submit">{{svg "octicon-plus" 16}} {{ctx.Locale.Tr "repo.wishlist.submit"}}</button>
<a class="ui button" href="{{.RepoLink}}/wishlist">{{ctx.Locale.Tr "cancel"}}</a>
</div>
</form>
</div>
</div>
{{template "base/footer" .}}

View File

@@ -0,0 +1,497 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wishlist">
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
<div class="wishlist-view-header">
<a class="wishlist-back-link" href="{{.RepoLink}}/wishlist">{{svg "octicon-arrow-left" 14}} {{ctx.Locale.Tr "repo.wishlist.back_to_list"}}</a>
</div>
<div class="wishlist-view-split">
<div class="wishlist-view-main">
<div class="wishlist-view-title-row">
<h1 class="wishlist-view-title">{{.Item.Title}}</h1>
<div class="wishlist-view-badges">
{{if .Item.Status.IsClosed}}
<span class="ui label {{if eq .Item.Status.String "completed"}}green{{else}}red{{end}}">{{ctx.Locale.Tr (printf "repo.wishlist.status_%s" .Item.Status.String)}}</span>
{{else}}
<span class="ui label green">{{ctx.Locale.Tr "repo.wishlist.status_open"}}</span>
{{end}}
{{if .Item.Category}}
<span class="wishlist-category-badge" style="background-color: {{.Item.Category.Color}}20; color: {{.Item.Category.Color}}; border: 1px solid {{.Item.Category.Color}}40;">{{.Item.Category.Name}}</span>
{{end}}
</div>
</div>
<div class="wishlist-view-meta">
{{if .Item.Author}}
<img class="ui avatar" src="{{.Item.Author.AvatarLink ctx}}" alt="{{.Item.Author.Name}}" width="20" height="20">
<span>{{.Item.Author.DisplayName}}</span>
<span class="wishlist-meta-sep">&middot;</span>
{{end}}
<span>{{DateUtils.TimeSince .Item.CreatedUnix}}</span>
{{if and .Item.Release .Item.Status.IsClosed}}
<span class="wishlist-meta-sep">&middot;</span>
<span>{{ctx.Locale.Tr "repo.wishlist.linked_release"}} <a href="{{.RepoLink}}/releases/tag/{{.Item.Release.TagName}}">{{.Item.Release.TagName}}</a></span>
{{end}}
</div>
{{if .Item.RenderedContent}}
<div class="wishlist-view-content markup">
{{.Item.RenderedContent | SafeHTML}}
</div>
{{end}}
<!-- Admin Actions -->
{{if .IsRepoAdmin}}
<div class="wishlist-admin-actions">
{{if not .Item.Status.IsClosed}}
<form class="ui form" method="post" action="{{.RepoLink}}/wishlist/{{.Item.ID}}/close" style="display: inline-flex; gap: 8px; align-items: center; flex-wrap: wrap;">
{{.CsrfTokenHtml}}
<select name="release_id" class="ui mini dropdown">
<option value="0">{{ctx.Locale.Tr "repo.wishlist.no_release"}}</option>
{{range .Releases}}
<option value="{{.ID}}">{{.TagName}} - {{.Title}}</option>
{{end}}
</select>
<button class="ui green mini button" type="submit" name="status" value="completed">{{svg "octicon-check" 14}} {{ctx.Locale.Tr "repo.wishlist.close_completed"}}</button>
<button class="ui red mini button" type="submit" name="status" value="will_not_do">{{svg "octicon-x" 14}} {{ctx.Locale.Tr "repo.wishlist.close_will_not_do"}}</button>
</form>
{{else}}
<form method="post" action="{{.RepoLink}}/wishlist/{{.Item.ID}}/reopen" style="display: inline;">
{{.CsrfTokenHtml}}
<button class="ui mini button" type="submit">{{svg "octicon-issue-reopened" 14}} {{ctx.Locale.Tr "repo.wishlist.reopen"}}</button>
</form>
{{end}}
</div>
{{end}}
<!-- Comments Section -->
<div class="wishlist-comments-section">
<h3>{{ctx.Locale.Tr "repo.wishlist.comments"}} ({{len .Comments}})</h3>
{{range .Comments}}
<div class="wishlist-comment" id="comment-{{.ID}}">
<div class="wishlist-comment-avatar">
{{if .User}}
<img class="ui avatar" src="{{.User.AvatarLink ctx}}" alt="{{.User.Name}}" width="32" height="32">
{{end}}
</div>
<div class="wishlist-comment-body">
<div class="wishlist-comment-header">
{{if .User}}<strong>{{.User.DisplayName}}</strong>{{end}}
<span class="wishlist-comment-time">{{DateUtils.TimeSince .CreatedUnix}}</span>
</div>
<div class="wishlist-comment-content">{{.Content}}</div>
<div class="wishlist-comment-actions">
<div class="wishlist-comment-reactions">
{{if $.IsSigned}}
<button class="wishlist-comment-reaction-btn{{if and (index $.UserReactions .ID) (index $.UserReactions .ID).IsLike}} active{{end}}"
data-url="{{$.RepoLink}}/wishlist/{{$.Item.ID}}/comment/{{.ID}}/react" data-type="like" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsup" 14}}
<span class="comment-like-count" data-comment-id="{{.ID}}">{{if index $.ReactionCounts .ID}}{{(index $.ReactionCounts .ID).Likes}}{{else}}0{{end}}</span>
</button>
<button class="wishlist-comment-reaction-btn{{if and (index $.UserReactions .ID) (not (index $.UserReactions .ID).IsLike)}} active{{end}}"
data-url="{{$.RepoLink}}/wishlist/{{$.Item.ID}}/comment/{{.ID}}/react" data-type="dislike" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsdown" 14}}
<span class="comment-dislike-count" data-comment-id="{{.ID}}">{{if index $.ReactionCounts .ID}}{{(index $.ReactionCounts .ID).Dislikes}}{{else}}0{{end}}</span>
</button>
{{else}}
<span class="wishlist-comment-reaction-display">
{{svg "octicon-thumbsup" 14}} {{if index $.ReactionCounts .ID}}{{(index $.ReactionCounts .ID).Likes}}{{else}}0{{end}}
</span>
<span class="wishlist-comment-reaction-display">
{{svg "octicon-thumbsdown" 14}} {{if index $.ReactionCounts .ID}}{{(index $.ReactionCounts .ID).Dislikes}}{{else}}0{{end}}
</span>
{{end}}
</div>
{{if $.IsRepoAdmin}}
<form method="post" action="{{$.RepoLink}}/wishlist/{{$.Item.ID}}/comment/{{.ID}}/delete" style="display:inline;">
{{$.CsrfTokenHtml}}
<button class="wishlist-comment-delete-btn" type="submit" title="{{ctx.Locale.Tr "delete"}}">{{svg "octicon-trash" 14}}</button>
</form>
{{end}}
</div>
</div>
</div>
{{end}}
{{if $.IsSigned}}
<div class="wishlist-comment-form">
<form class="ui form" method="post" action="{{.RepoLink}}/wishlist/{{.Item.ID}}/comment">
{{.CsrfTokenHtml}}
<div class="field">
<textarea name="content" rows="3" required placeholder="{{ctx.Locale.Tr "repo.wishlist.comment_placeholder"}}"></textarea>
</div>
<button class="ui primary small button" type="submit">{{svg "octicon-comment" 14}} {{ctx.Locale.Tr "repo.wishlist.add_comment"}}</button>
</form>
</div>
{{end}}
</div>
</div>
<div class="wishlist-view-sidebar">
<!-- Vote -->
<div class="wishlist-sidebar-section">
<h5>{{ctx.Locale.Tr "repo.wishlist.votes"}}</h5>
<div class="wishlist-vote-display">
<span class="wishlist-vote-big">{{.Item.VoteCount}}</span>
<span>{{ctx.Locale.Tr "repo.wishlist.votes_label"}}</span>
</div>
{{if and .IsSigned (not .Item.Status.IsClosed)}}
<form method="post" action="{{.RepoLink}}/wishlist/{{.Item.ID}}/vote" class="tw-mt-2">
{{.CsrfTokenHtml}}
{{if .UserVoted}}
<button class="ui fluid small button red" type="submit">{{svg "octicon-triangle-up" 14}} {{ctx.Locale.Tr "repo.wishlist.remove_vote"}}</button>
{{else}}
<button class="ui fluid small button primary" type="submit"{{if le .RemainingVotes 0}} disabled title="{{ctx.Locale.Tr "repo.wishlist.no_votes_left"}}"{{end}}>
{{svg "octicon-triangle-up" 14}} {{ctx.Locale.Tr "repo.wishlist.add_vote"}}
</button>
{{if le .RemainingVotes 0}}
<p class="wishlist-vote-warning">{{ctx.Locale.Tr "repo.wishlist.no_votes_left"}}</p>
{{end}}
{{end}}
</form>
{{end}}
{{if .IsSigned}}
<div class="wishlist-vote-budget tw-mt-2">
<span class="wishlist-vote-budget-count">{{.RemainingVotes}}/{{.MaxVotes}}</span>
<span>{{ctx.Locale.Tr "repo.wishlist.votes_remaining"}}</span>
</div>
{{end}}
</div>
<!-- Importance -->
<div class="wishlist-sidebar-section">
<h5>{{ctx.Locale.Tr "repo.wishlist.importance"}}</h5>
{{if .IsSigned}}
<form method="post" action="{{.RepoLink}}/wishlist/{{.Item.ID}}/importance" class="wishlist-importance-form">
{{.CsrfTokenHtml}}
<label class="wishlist-importance-option{{if eq .UserImportance 0}} active{{end}}">
<input type="radio" name="rating" value="0"{{if eq .UserImportance 0}} checked{{end}} onchange="this.form.submit()">
{{ctx.Locale.Tr "repo.wishlist.not_interested"}}
</label>
<label class="wishlist-importance-option{{if eq .UserImportance 1}} active{{end}}">
<input type="radio" name="rating" value="1"{{if eq .UserImportance 1}} checked{{end}} onchange="this.form.submit()">
{{svg "octicon-star" 12}} {{ctx.Locale.Tr "repo.wishlist.important"}}
</label>
<label class="wishlist-importance-option{{if eq .UserImportance 2}} active{{end}}">
<input type="radio" name="rating" value="2"{{if eq .UserImportance 2}} checked{{end}} onchange="this.form.submit()">
{{svg "octicon-flame" 12}} {{ctx.Locale.Tr "repo.wishlist.required"}}
</label>
</form>
{{end}}
{{if .ImportanceSummary}}
<div class="wishlist-importance-summary">
{{if gt .ImportanceSummary.Important 0}}<span>{{svg "octicon-star" 12}} {{.ImportanceSummary.Important}} {{ctx.Locale.Tr "repo.wishlist.important"}}</span>{{end}}
{{if gt .ImportanceSummary.Required 0}}<span>{{svg "octicon-flame" 12}} {{.ImportanceSummary.Required}} {{ctx.Locale.Tr "repo.wishlist.required"}}</span>{{end}}
</div>
{{end}}
</div>
</div>
</div>
</div>
</div>
<style>
.wishlist-back-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--color-text-light);
text-decoration: none;
margin-bottom: 16px;
}
.wishlist-back-link:hover {
color: var(--color-primary);
}
.wishlist-view-split {
display: flex;
gap: 24px;
}
.wishlist-view-main {
flex: 1;
min-width: 0;
}
.wishlist-view-sidebar {
width: 260px;
flex-shrink: 0;
}
.wishlist-view-title-row {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.wishlist-view-title {
font-size: 24px;
margin: 0;
flex: 1;
min-width: 0;
}
.wishlist-view-badges {
display: flex;
gap: 6px;
align-items: center;
flex-shrink: 0;
padding-top: 4px;
}
.wishlist-category-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.wishlist-view-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--color-text-light);
margin: 8px 0 16px 0;
}
.wishlist-meta-sep {
color: var(--color-text-light-3);
}
.wishlist-view-content {
padding: 20px 0;
border-bottom: 1px solid var(--color-secondary);
}
.wishlist-admin-actions {
padding: 16px 0;
border-bottom: 1px solid var(--color-secondary);
}
.wishlist-comments-section {
margin-top: 24px;
}
.wishlist-comments-section h3 {
font-size: 16px;
margin: 0 0 16px 0;
}
.wishlist-comment {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--color-secondary-alpha-40);
}
.wishlist-comment-avatar {
flex-shrink: 0;
}
.wishlist-comment-body {
flex: 1;
min-width: 0;
}
.wishlist-comment-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.wishlist-comment-time {
color: var(--color-text-light);
}
.wishlist-comment-content {
margin: 4px 0 8px 0;
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
}
.wishlist-comment-actions {
display: flex;
align-items: center;
gap: 12px;
}
.wishlist-comment-reactions {
display: flex;
align-items: center;
gap: 4px;
}
.wishlist-comment-reaction-btn {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border: 1px solid var(--color-secondary);
border-radius: 12px;
background: transparent;
cursor: pointer;
font-size: 12px;
color: var(--color-text-light);
transition: all 0.15s;
}
.wishlist-comment-reaction-btn:hover {
background: var(--color-secondary-alpha-20);
color: var(--color-text);
}
.wishlist-comment-reaction-btn.active {
background: var(--color-primary-alpha-20);
border-color: var(--color-primary);
color: var(--color-primary);
}
.wishlist-comment-reaction-btn.active[data-type="dislike"] {
background: var(--color-red-alpha-20, rgba(219, 40, 40, 0.1));
border-color: var(--color-red);
color: var(--color-red);
}
.wishlist-comment-reaction-display {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 12px;
color: var(--color-text-light);
}
.wishlist-comment-delete-btn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-light);
padding: 2px 4px;
border-radius: 4px;
transition: all 0.15s;
}
.wishlist-comment-delete-btn:hover {
color: var(--color-red);
background: var(--color-red-alpha-20, rgba(219, 40, 40, 0.1));
}
.wishlist-comment-form {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--color-secondary);
}
.wishlist-sidebar-section {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--color-secondary-alpha-40);
}
.wishlist-sidebar-section:last-child {
border-bottom: none;
}
.wishlist-sidebar-section h5 {
margin: 0 0 8px 0;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-light);
}
.wishlist-vote-display {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 13px;
color: var(--color-text-light);
}
.wishlist-vote-big {
font-size: 28px;
font-weight: 700;
color: var(--color-text);
line-height: 1;
}
.wishlist-vote-budget {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-text-light);
}
.wishlist-vote-budget-count {
font-weight: 600;
color: var(--color-primary);
}
.wishlist-vote-warning {
font-size: 12px;
color: var(--color-red);
margin: 4px 0 0 0;
}
.wishlist-importance-form {
display: flex;
flex-direction: column;
gap: 4px;
}
.wishlist-importance-option {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
.wishlist-importance-option:hover {
background: var(--color-secondary-alpha-20);
}
.wishlist-importance-option.active {
background: var(--color-primary-alpha-20);
color: var(--color-primary);
font-weight: 500;
}
.wishlist-importance-option input[type="radio"] {
display: none;
}
.wishlist-importance-summary {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
font-size: 12px;
color: var(--color-text-light);
}
.wishlist-importance-summary span {
display: inline-flex;
align-items: center;
gap: 4px;
}
@media (max-width: 768px) {
.wishlist-view-split {
flex-direction: column-reverse;
}
.wishlist-view-sidebar {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.wishlist-sidebar-section {
flex: 1;
min-width: 150px;
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
}
</style>
<script>
// Comment reaction toggle (AJAX)
document.querySelectorAll('.wishlist-comment-reaction-btn').forEach(function(btn) {
btn.addEventListener('click', async function() {
const url = this.dataset.url;
const type = this.dataset.type;
const commentId = this.dataset.commentId;
try {
const resp = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: '_csrf=' + encodeURIComponent(document.querySelector('meta[name="_csrf"]')?.content || '') + '&type=' + type,
});
if (resp.ok) {
const data = await resp.json();
document.querySelectorAll('.comment-like-count[data-comment-id="' + commentId + '"]').forEach(el => el.textContent = data.likes);
document.querySelectorAll('.comment-dislike-count[data-comment-id="' + commentId + '"]').forEach(el => el.textContent = data.dislikes);
document.querySelectorAll('.wishlist-comment-reaction-btn[data-comment-id="' + commentId + '"]').forEach(b => {
b.classList.remove('active');
if (data.reacted && b.dataset.type === data.type) {
b.classList.add('active');
}
});
}
} catch (e) {
console.error('Reaction error:', e);
}
});
});
</script>
{{template "base/footer" .}}