diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index b5aafe24c0..1288ac35f2 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_26/v356.go b/models/migrations/v1_26/v356.go new file mode 100644 index 0000000000..80e31079f6 --- /dev/null +++ b/models/migrations/v1_26/v356.go @@ -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)) +} diff --git a/models/migrations/v1_26/v357.go b/models/migrations/v1_26/v357.go new file mode 100644 index 0000000000..6cb6094e12 --- /dev/null +++ b/models/migrations/v1_26/v357.go @@ -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)) +} diff --git a/models/migrations/v1_26/v358.go b/models/migrations/v1_26/v358.go new file mode 100644 index 0000000000..ede619afdc --- /dev/null +++ b/models/migrations/v1_26/v358.go @@ -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)) +} diff --git a/models/migrations/v1_26/v359.go b/models/migrations/v1_26/v359.go new file mode 100644 index 0000000000..0b0de18d90 --- /dev/null +++ b/models/migrations/v1_26/v359.go @@ -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)) +} diff --git a/models/migrations/v1_26/v360.go b/models/migrations/v1_26/v360.go new file mode 100644 index 0000000000..da01881361 --- /dev/null +++ b/models/migrations/v1_26/v360.go @@ -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)) +} diff --git a/models/migrations/v1_26/v361.go b/models/migrations/v1_26/v361.go new file mode 100644 index 0000000000..694069d37a --- /dev/null +++ b/models/migrations/v1_26/v361.go @@ -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)) +} diff --git a/models/migrations/v1_26/v362.go b/models/migrations/v1_26/v362.go new file mode 100644 index 0000000000..8c959540b9 --- /dev/null +++ b/models/migrations/v1_26/v362.go @@ -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)) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 7e0f92bc81..4b21a28283 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -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 diff --git a/models/wishlist/wishlist_category.go b/models/wishlist/wishlist_category.go new file mode 100644 index 0000000000..ef047dd8bd --- /dev/null +++ b/models/wishlist/wishlist_category.go @@ -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 +} diff --git a/models/wishlist/wishlist_comment.go b/models/wishlist/wishlist_comment.go new file mode 100644 index 0000000000..1ddba97a9f --- /dev/null +++ b/models/wishlist/wishlist_comment.go @@ -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 +} diff --git a/models/wishlist/wishlist_comment_reaction.go b/models/wishlist/wishlist_comment_reaction.go new file mode 100644 index 0000000000..b89efd3390 --- /dev/null +++ b/models/wishlist/wishlist_comment_reaction.go @@ -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 +} diff --git a/models/wishlist/wishlist_importance.go b/models/wishlist/wishlist_importance.go new file mode 100644 index 0000000000..c121a9d872 --- /dev/null +++ b/models/wishlist/wishlist_importance.go @@ -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 +} diff --git a/models/wishlist/wishlist_item.go b/models/wishlist/wishlist_item.go new file mode 100644 index 0000000000..11d5d99e05 --- /dev/null +++ b/models/wishlist/wishlist_item.go @@ -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)) +} diff --git a/models/wishlist/wishlist_vote.go b/models/wishlist/wishlist_vote.go new file mode 100644 index 0000000000..6b969e9e0f --- /dev/null +++ b/models/wishlist/wishlist_vote.go @@ -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 +} diff --git a/modules/errors/codes.go b/modules/errors/codes.go index 9183c89356..b508c43f77 100644 --- a/modules/errors/codes.go +++ b/modules/errors/codes.go @@ -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 diff --git a/modules/structs/wishlist.go b/modules/structs/wishlist.go new file mode 100644 index 0000000000..ac1b25eea9 --- /dev/null +++ b/modules/structs/wishlist.go @@ -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"` +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 78aaa51c56..6a88364ec7 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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.", diff --git a/routers/api/v2/api.go b/routers/api/v2/api.go index cf4322cfa8..1f4eb021fc 100644 --- a/routers/api/v2/api.go +++ b/routers/api/v2/api.go @@ -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) diff --git a/routers/api/v2/wishlist.go b/routers/api/v2/wishlist.go new file mode 100644 index 0000000000..077acf8fdf --- /dev/null +++ b/routers/api/v2/wishlist.go @@ -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 +} diff --git a/routers/web/repo/setting/wishlist.go b/routers/web/repo/setting/wishlist.go new file mode 100644 index 0000000000..fdd31e1748 --- /dev/null +++ b/routers/web/repo/setting/wishlist.go @@ -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") +} diff --git a/routers/web/repo/wishlist.go b/routers/web/repo/wishlist.go new file mode 100644 index 0000000000..1807c01e46 --- /dev/null +++ b/routers/web/repo/wishlist.go @@ -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)) +} diff --git a/routers/web/web.go b/routers/web/web.go index 0ff90a383e..73d948e104 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/context/repo.go b/services/context/repo.go index 6b7c90bb1c..0e1d6d71e3 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -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 diff --git a/services/forms/wishlist_form.go b/services/forms/wishlist_form.go new file mode 100644 index 0000000000..0073864106 --- /dev/null +++ b/services/forms/wishlist_form.go @@ -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) +} diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index f2842813e2..630f4f62fe 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -222,6 +222,13 @@ {{end}} {{end}} + {{if .Repository.WishlistEnabled}} + + {{svg "octicon-light-bulb"}} {{ctx.Locale.Tr "repo.wishlist"}} + {{if gt .WishlistOpenCount 0}}{{.WishlistOpenCount}}{{end}} + + {{end}} + {{if .Permission.CanRead ctx.Consts.RepoUnitTypeExternalWiki}} {{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.wiki"}} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index 496326fa11..8c76c95cb9 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -117,5 +117,8 @@ {{end}} + + {{ctx.Locale.Tr "repo.settings.wishlist"}} + diff --git a/templates/repo/settings/wishlist.tmpl b/templates/repo/settings/wishlist.tmpl new file mode 100644 index 0000000000..beae87c4ad --- /dev/null +++ b/templates/repo/settings/wishlist.tmpl @@ -0,0 +1,84 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings wishlist")}} +
+

+ {{ctx.Locale.Tr "repo.settings.wishlist"}} +

+
+
+ {{.CsrfTokenHtml}} +
+
+ + +
+
+

{{ctx.Locale.Tr "repo.settings.wishlist.enable_help"}}

+
+ +
+
+
+ + {{if .Repository.WishlistEnabled}} +

+ {{ctx.Locale.Tr "repo.settings.wishlist.categories"}} +

+
+ {{if .Categories}} + + + + + + + + + + + {{range .Categories}} + + + {{$.CsrfTokenHtml}} + + + + + + {{end}} + +
{{ctx.Locale.Tr "repo.settings.wishlist.category_name"}}{{ctx.Locale.Tr "repo.settings.wishlist.category_color"}}{{ctx.Locale.Tr "repo.settings.wishlist.category_sort"}}
+ + +
+ {{$.CsrfTokenHtml}} + +
+
+ {{else}} +

{{ctx.Locale.Tr "repo.settings.wishlist.no_categories"}}

+ {{end}} + +
+
{{ctx.Locale.Tr "repo.settings.wishlist.add_category"}}
+
+ {{.CsrfTokenHtml}} +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ {{end}} +
+{{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/wishlist/list.tmpl b/templates/repo/wishlist/list.tmpl new file mode 100644 index 0000000000..46646c9709 --- /dev/null +++ b/templates/repo/wishlist/list.tmpl @@ -0,0 +1,351 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+ {{template "base/alert" .}} + + + +
+
+ {{if .Items}} +
+ {{range .Items}} +
+
+
+ {{svg "octicon-triangle-up" 18}} + {{.VoteCount}} +
+
+
+ {{.Title}} +
+ {{if .Category}} + {{.Category.Name}} + {{end}} + {{if .Status.IsClosed}} + {{ctx.Locale.Tr (printf "repo.wishlist.status_%s" .Status.String)}} + {{end}} + {{if .Author}} + {{.Author.DisplayName}} + · + {{end}} + {{DateUtils.TimeSince .CreatedUnix}} + {{if and $.CommentCounts (index $.CommentCounts .ID)}} + · + {{svg "octicon-comment" 12}} {{index $.CommentCounts .ID}} + {{end}} + {{if and $.ImportanceSummaries (index $.ImportanceSummaries .ID)}} + {{with index $.ImportanceSummaries .ID}} + {{if or (gt .Important 0) (gt .Required 0)}} + · + + {{if gt .Required 0}}{{svg "octicon-flame" 12}} {{.Required}}{{end}} + {{if gt .Important 0}}{{svg "octicon-star" 12}} {{.Important}}{{end}} + + {{end}} + {{end}} + {{end}} +
+
+
+ {{end}} +
+ + {{if gt .PageCount 1}} +
+ {{if gt .Page 1}} + {{ctx.Locale.Tr "repo.wishlist.prev"}} + {{end}} + {{ctx.Locale.Tr "repo.wishlist.page_info" .Page .PageCount}} + {{if lt .Page .PageCount}} + {{ctx.Locale.Tr "repo.wishlist.next"}} + {{end}} +
+ {{end}} + + {{else}} +
+ {{svg "octicon-light-bulb" 48}} +

{{ctx.Locale.Tr "repo.wishlist.no_items"}}

+ {{if .IsSigned}} +

{{ctx.Locale.Tr "repo.wishlist.no_items_hint"}}

+ {{end}} +
+ {{end}} +
+ +
+ {{if .IsSigned}} +
+
{{ctx.Locale.Tr "repo.wishlist.your_votes"}}
+
+ {{.RemainingVotes}}/{{.MaxVotes}} + {{ctx.Locale.Tr "repo.wishlist.votes_remaining"}} +
+
+ {{end}} + + + + {{if .Categories}} +
+
{{ctx.Locale.Tr "repo.wishlist.categories"}}
+ +
+ {{end}} +
+
+
+
+ + +{{template "base/footer" .}} diff --git a/templates/repo/wishlist/new.tmpl b/templates/repo/wishlist/new.tmpl new file mode 100644 index 0000000000..26463acc45 --- /dev/null +++ b/templates/repo/wishlist/new.tmpl @@ -0,0 +1,40 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+ {{template "base/alert" .}} + +

+ {{ctx.Locale.Tr "repo.wishlist.new_item"}} +

+ +
+ {{.CsrfTokenHtml}} +
+ + +
+
+ + +

{{ctx.Locale.Tr "repo.wishlist.content_help"}}

+
+ {{if .Categories}} +
+ + +
+ {{end}} +
+ + {{ctx.Locale.Tr "cancel"}} +
+
+
+
+{{template "base/footer" .}} diff --git a/templates/repo/wishlist/view.tmpl b/templates/repo/wishlist/view.tmpl new file mode 100644 index 0000000000..2de4a50692 --- /dev/null +++ b/templates/repo/wishlist/view.tmpl @@ -0,0 +1,497 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+ {{template "base/alert" .}} + + + +
+
+
+

{{.Item.Title}}

+
+ {{if .Item.Status.IsClosed}} + {{ctx.Locale.Tr (printf "repo.wishlist.status_%s" .Item.Status.String)}} + {{else}} + {{ctx.Locale.Tr "repo.wishlist.status_open"}} + {{end}} + {{if .Item.Category}} + {{.Item.Category.Name}} + {{end}} +
+
+ +
+ {{if .Item.Author}} + {{.Item.Author.Name}} + {{.Item.Author.DisplayName}} + · + {{end}} + {{DateUtils.TimeSince .Item.CreatedUnix}} + {{if and .Item.Release .Item.Status.IsClosed}} + · + {{ctx.Locale.Tr "repo.wishlist.linked_release"}} {{.Item.Release.TagName}} + {{end}} +
+ + {{if .Item.RenderedContent}} +
+ {{.Item.RenderedContent | SafeHTML}} +
+ {{end}} + + + {{if .IsRepoAdmin}} +
+ {{if not .Item.Status.IsClosed}} +
+ {{.CsrfTokenHtml}} + + + +
+ {{else}} +
+ {{.CsrfTokenHtml}} + +
+ {{end}} +
+ {{end}} + + +
+

{{ctx.Locale.Tr "repo.wishlist.comments"}} ({{len .Comments}})

+ + {{range .Comments}} +
+
+ {{if .User}} + {{.User.Name}} + {{end}} +
+
+
+ {{if .User}}{{.User.DisplayName}}{{end}} + {{DateUtils.TimeSince .CreatedUnix}} +
+
{{.Content}}
+
+
+ {{if $.IsSigned}} + + + {{else}} + + {{svg "octicon-thumbsup" 14}} {{if index $.ReactionCounts .ID}}{{(index $.ReactionCounts .ID).Likes}}{{else}}0{{end}} + + + {{svg "octicon-thumbsdown" 14}} {{if index $.ReactionCounts .ID}}{{(index $.ReactionCounts .ID).Dislikes}}{{else}}0{{end}} + + {{end}} +
+ {{if $.IsRepoAdmin}} +
+ {{$.CsrfTokenHtml}} + +
+ {{end}} +
+
+
+ {{end}} + + {{if $.IsSigned}} +
+
+ {{.CsrfTokenHtml}} +
+ +
+ +
+
+ {{end}} +
+
+ +
+ +
+
{{ctx.Locale.Tr "repo.wishlist.votes"}}
+
+ {{.Item.VoteCount}} + {{ctx.Locale.Tr "repo.wishlist.votes_label"}} +
+ {{if and .IsSigned (not .Item.Status.IsClosed)}} +
+ {{.CsrfTokenHtml}} + {{if .UserVoted}} + + {{else}} + + {{if le .RemainingVotes 0}} +

{{ctx.Locale.Tr "repo.wishlist.no_votes_left"}}

+ {{end}} + {{end}} +
+ {{end}} + {{if .IsSigned}} +
+ {{.RemainingVotes}}/{{.MaxVotes}} + {{ctx.Locale.Tr "repo.wishlist.votes_remaining"}} +
+ {{end}} +
+ + +
+
{{ctx.Locale.Tr "repo.wishlist.importance"}}
+ {{if .IsSigned}} +
+ {{.CsrfTokenHtml}} + + + +
+ {{end}} + {{if .ImportanceSummary}} +
+ {{if gt .ImportanceSummary.Important 0}}{{svg "octicon-star" 12}} {{.ImportanceSummary.Important}} {{ctx.Locale.Tr "repo.wishlist.important"}}{{end}} + {{if gt .ImportanceSummary.Required 0}}{{svg "octicon-flame" 12}} {{.ImportanceSummary.Required}} {{ctx.Locale.Tr "repo.wishlist.required"}}{{end}} +
+ {{end}} +
+
+
+
+
+ + + + +{{template "base/footer" .}}