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
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:
@@ -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
|
||||
}
|
||||
|
||||
13
models/migrations/v1_26/v356.go
Normal file
13
models/migrations/v1_26/v356.go
Normal 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))
|
||||
}
|
||||
22
models/migrations/v1_26/v357.go
Normal file
22
models/migrations/v1_26/v357.go
Normal 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))
|
||||
}
|
||||
28
models/migrations/v1_26/v358.go
Normal file
28
models/migrations/v1_26/v358.go
Normal 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))
|
||||
}
|
||||
20
models/migrations/v1_26/v359.go
Normal file
20
models/migrations/v1_26/v359.go
Normal 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))
|
||||
}
|
||||
21
models/migrations/v1_26/v360.go
Normal file
21
models/migrations/v1_26/v360.go
Normal 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))
|
||||
}
|
||||
22
models/migrations/v1_26/v361.go
Normal file
22
models/migrations/v1_26/v361.go
Normal 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))
|
||||
}
|
||||
21
models/migrations/v1_26/v362.go
Normal file
21
models/migrations/v1_26/v362.go
Normal 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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
83
models/wishlist/wishlist_category.go
Normal file
83
models/wishlist/wishlist_category.go
Normal 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
|
||||
}
|
||||
126
models/wishlist/wishlist_comment.go
Normal file
126
models/wishlist/wishlist_comment.go
Normal 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
|
||||
}
|
||||
158
models/wishlist/wishlist_comment_reaction.go
Normal file
158
models/wishlist/wishlist_comment_reaction.go
Normal 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
|
||||
}
|
||||
169
models/wishlist/wishlist_importance.go
Normal file
169
models/wishlist/wishlist_importance.go
Normal 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
|
||||
}
|
||||
222
models/wishlist/wishlist_item.go
Normal file
222
models/wishlist/wishlist_item.go
Normal 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))
|
||||
}
|
||||
99
models/wishlist/wishlist_vote.go
Normal file
99
models/wishlist/wishlist_vote.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
91
modules/structs/wishlist.go
Normal file
91
modules/structs/wishlist.go
Normal 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"`
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
514
routers/api/v2/wishlist.go
Normal 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
|
||||
}
|
||||
109
routers/web/repo/setting/wishlist.go
Normal file
109
routers/web/repo/setting/wishlist.go
Normal 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")
|
||||
}
|
||||
437
routers/web/repo/wishlist.go
Normal file
437
routers/web/repo/wishlist.go
Normal 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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
50
services/forms/wishlist_form.go
Normal file
50
services/forms/wishlist_form.go
Normal 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)
|
||||
}
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
templates/repo/settings/wishlist.tmpl
Normal file
84
templates/repo/settings/wishlist.tmpl
Normal 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" .}}
|
||||
351
templates/repo/wishlist/list.tmpl
Normal file
351
templates/repo/wishlist/list.tmpl
Normal 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">·</span>
|
||||
{{end}}
|
||||
<span class="wishlist-meta-text">{{DateUtils.TimeSince .CreatedUnix}}</span>
|
||||
{{if and $.CommentCounts (index $.CommentCounts .ID)}}
|
||||
<span class="wishlist-meta-sep">·</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">·</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}}—{{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}}—{{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" .}}
|
||||
40
templates/repo/wishlist/new.tmpl
Normal file
40
templates/repo/wishlist/new.tmpl
Normal 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" .}}
|
||||
497
templates/repo/wishlist/view.tmpl
Normal file
497
templates/repo/wishlist/view.tmpl
Normal 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">·</span>
|
||||
{{end}}
|
||||
<span>{{DateUtils.TimeSince .Item.CreatedUnix}}</span>
|
||||
{{if and .Item.Release .Item.Status.IsClosed}}
|
||||
<span class="wishlist-meta-sep">·</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" .}}
|
||||
Reference in New Issue
Block a user