diff --git a/models/user/user_pinned.go b/models/user/user_pinned.go new file mode 100644 index 0000000000..0bbcbde7b7 --- /dev/null +++ b/models/user/user_pinned.go @@ -0,0 +1,137 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" +) + +// UserPinnedRepo represents a pinned repository for a user's profile +type UserPinnedRepo struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX NOT NULL"` + RepoID int64 `xorm:"INDEX NOT NULL"` + DisplayOrder int `xorm:"DEFAULT 0"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + + Repo any `xorm:"-"` // Will be loaded by caller to avoid import cycle +} + +// TableName returns the table name for UserPinnedRepo +func (p *UserPinnedRepo) TableName() string { + return "user_pinned_repo" +} + +func init() { + db.RegisterModel(new(UserPinnedRepo)) +} + +// MaxUserPinnedRepos is the maximum number of repos a user can pin +const MaxUserPinnedRepos = 6 + +// GetUserPinnedRepos returns all pinned repos for a user +func GetUserPinnedRepos(ctx context.Context, userID int64) ([]*UserPinnedRepo, error) { + pinnedRepos := make([]*UserPinnedRepo, 0, MaxUserPinnedRepos) + err := db.GetEngine(ctx). + Where("user_id = ?", userID). + OrderBy("display_order ASC, id ASC"). + Find(&pinnedRepos) + return pinnedRepos, err +} + +// CountUserPinnedRepos returns the count of pinned repos for a user +func CountUserPinnedRepos(ctx context.Context, userID int64) (int64, error) { + return db.GetEngine(ctx).Where("user_id = ?", userID).Count(new(UserPinnedRepo)) +} + +// IsRepoPinnedByUser checks if a repo is pinned by a user +func IsRepoPinnedByUser(ctx context.Context, userID, repoID int64) (bool, error) { + return db.GetEngine(ctx).Where("user_id = ? AND repo_id = ?", userID, repoID).Exist(new(UserPinnedRepo)) +} + +// PinRepoToUser pins a repo to a user's profile +func PinRepoToUser(ctx context.Context, userID, repoID int64) error { + // Check if already pinned + exists, err := IsRepoPinnedByUser(ctx, userID, repoID) + if err != nil { + return err + } + if exists { + return nil // Already pinned + } + + // Check max limit + count, err := CountUserPinnedRepos(ctx, userID) + if err != nil { + return err + } + if count >= MaxUserPinnedRepos { + return ErrUserPinnedRepoLimit{UserID: userID, Limit: MaxUserPinnedRepos} + } + + // Get next display order + var maxOrder int + _, err = db.GetEngine(ctx). + Table("user_pinned_repo"). + Where("user_id = ?", userID). + Select("COALESCE(MAX(display_order), 0)"). + Get(&maxOrder) + if err != nil { + return err + } + + pinnedRepo := &UserPinnedRepo{ + UserID: userID, + RepoID: repoID, + DisplayOrder: maxOrder + 1, + } + + _, err = db.GetEngine(ctx).Insert(pinnedRepo) + return err +} + +// UnpinRepoFromUser unpins a repo from a user's profile +func UnpinRepoFromUser(ctx context.Context, userID, repoID int64) error { + _, err := db.GetEngine(ctx).Where("user_id = ? AND repo_id = ?", userID, repoID).Delete(new(UserPinnedRepo)) + return err +} + +// UpdateUserPinnedRepoOrder updates the display order of pinned repos +func UpdateUserPinnedRepoOrder(ctx context.Context, userID int64, repoIDs []int64) error { + for i, repoID := range repoIDs { + _, err := db.GetEngine(ctx). + Where("user_id = ? AND repo_id = ?", userID, repoID). + Cols("display_order"). + Update(&UserPinnedRepo{DisplayOrder: i}) + if err != nil { + return err + } + } + return nil +} + +// DeleteUserPinnedReposByRepoID deletes all pins for a repo (when repo is deleted) +func DeleteUserPinnedReposByRepoID(ctx context.Context, repoID int64) error { + _, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(new(UserPinnedRepo)) + return err +} + +// ErrUserPinnedRepoLimit represents an error when user has reached pin limit +type ErrUserPinnedRepoLimit struct { + UserID int64 + Limit int +} + +func (err ErrUserPinnedRepoLimit) Error() string { + return "user has reached the maximum number of pinned repositories" +} + +// IsErrUserPinnedRepoLimit checks if error is ErrUserPinnedRepoLimit +func IsErrUserPinnedRepoLimit(err error) bool { + _, ok := err.(ErrUserPinnedRepoLimit) + return ok +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index fd91f31cba..346ab1c8e4 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3791,5 +3791,26 @@ "org.profile_readme_empty_desc": "Create a .profile repository with a README.md to introduce your organization.", "org.create_profile_repo": "Create Profile Repository", "org.activity": "Activity", - "org.repositories": "Repositories" + "org.repositories": "Repositories", + "repo.pin": "Pin", + "repo.pin.tooltip": "Pin this repository", + "repo.pin.pin_to_profile": "Pin to your profile", + "repo.pin.unpin_from_profile": "Unpin from profile", + "repo.pin.pin_to_org": "Pin to organization", + "repo.pin.unpin_from_org": "Unpin from organization", + "repo.pin.success_profile": "Repository pinned to your profile", + "repo.pin.success_org": "Repository pinned to organization", + "repo.pin.unpin_success_profile": "Repository unpinned from your profile", + "repo.pin.unpin_success_org": "Repository unpinned from organization", + "repo.pin.already_pinned_org": "Repository is already pinned", + "repo.pin.error_limit": "You have reached the maximum number of pinned repositories (6)", + "repo.pin.error_org_limit": "Organization has reached the maximum number of pinned repositories", + "repo.pin.error_not_org": "This repository does not belong to an organization", + "repo.pin.error_not_member": "You must be a member of the organization to pin repositories", + "repo.pin.error_generic": "Failed to update pin status", + "repo.pin.error_invalid_type": "Invalid pin type", + "user.pinned_repos": "Pinned Repositories", + "user.pinned_repos_hint": "Pin repos from the repo page", + "user.pinned_repos_empty_title": "No pinned repositories", + "user.pinned_repos_empty_desc": "Pin repositories to showcase your best work. Visit a repository and use the Pin dropdown." } \ No newline at end of file diff --git a/routers/web/repo/pin.go b/routers/web/repo/pin.go new file mode 100644 index 0000000000..0a076143fb --- /dev/null +++ b/routers/web/repo/pin.go @@ -0,0 +1,128 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" +) + +// Pin handles pinning a repo to user profile or organization +func Pin(ctx *context.Context) { + pinType := ctx.FormString("type") + redirectTo := ctx.FormString("redirect_to") + if redirectTo == "" { + redirectTo = ctx.Repo.RepoLink + } + + switch pinType { + case "user": + if err := user_model.PinRepoToUser(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID); err != nil { + if user_model.IsErrUserPinnedRepoLimit(err) { + ctx.Flash.Error(ctx.Tr("repo.pin.error_limit")) + } else { + log.Error("PinRepoToUser failed: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pin.error_generic")) + } + } else { + ctx.Flash.Success(ctx.Tr("repo.pin.success_profile")) + } + + case "org": + if !ctx.Repo.Repository.Owner.IsOrganization() { + ctx.Flash.Error(ctx.Tr("repo.pin.error_not_org")) + ctx.Redirect(redirectTo) + return + } + + // Check if user is a member of the org + isMember, err := organization.IsOrganizationMember(ctx, ctx.Repo.Repository.OwnerID, ctx.Doer.ID) + if err != nil { + log.Error("IsOrganizationMember failed: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pin.error_generic")) + ctx.Redirect(redirectTo) + return + } + if !isMember { + ctx.Flash.Error(ctx.Tr("repo.pin.error_not_member")) + ctx.Redirect(redirectTo) + return + } + + // Use CreateOrgPinnedRepo + pinnedRepo := &organization.OrgPinnedRepo{ + OrgID: ctx.Repo.Repository.OwnerID, + RepoID: ctx.Repo.Repository.ID, + } + if err := organization.CreateOrgPinnedRepo(ctx, pinnedRepo); err != nil { + if _, ok := err.(organization.ErrOrgPinnedRepoAlreadyExist); ok { + ctx.Flash.Info(ctx.Tr("repo.pin.already_pinned_org")) + } else { + log.Error("CreateOrgPinnedRepo failed: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pin.error_generic")) + } + } else { + ctx.Flash.Success(ctx.Tr("repo.pin.success_org")) + } + + default: + ctx.Flash.Error(ctx.Tr("repo.pin.error_invalid_type")) + } + + ctx.Redirect(redirectTo) +} + +// Unpin handles unpinning a repo from user profile or organization +func Unpin(ctx *context.Context) { + pinType := ctx.FormString("type") + redirectTo := ctx.FormString("redirect_to") + if redirectTo == "" { + redirectTo = ctx.Repo.RepoLink + } + + switch pinType { + case "user": + if err := user_model.UnpinRepoFromUser(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID); err != nil { + log.Error("UnpinRepoFromUser failed: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pin.error_generic")) + } else { + ctx.Flash.Success(ctx.Tr("repo.pin.unpin_success_profile")) + } + + case "org": + if !ctx.Repo.Repository.Owner.IsOrganization() { + ctx.Flash.Error(ctx.Tr("repo.pin.error_not_org")) + ctx.Redirect(redirectTo) + return + } + + // Check if user is a member of the org + isMember, err := organization.IsOrganizationMember(ctx, ctx.Repo.Repository.OwnerID, ctx.Doer.ID) + if err != nil { + log.Error("IsOrganizationMember failed: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pin.error_generic")) + ctx.Redirect(redirectTo) + return + } + if !isMember { + ctx.Flash.Error(ctx.Tr("repo.pin.error_not_member")) + ctx.Redirect(redirectTo) + return + } + + if err := organization.DeleteOrgPinnedRepo(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID); err != nil { + log.Error("DeleteOrgPinnedRepo failed: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pin.error_generic")) + } else { + ctx.Flash.Success(ctx.Tr("repo.pin.unpin_success_org")) + } + + default: + ctx.Flash.Error(ctx.Tr("repo.pin.error_invalid_type")) + } + + ctx.Redirect(redirectTo) +} diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index d7052914b6..a98dc956d7 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -79,15 +79,10 @@ func userProfile(ctx *context.Context) { } func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.Repository, profileReadme *git.Blob) { - // if there is a profile readme, default to "overview" page, otherwise, default to "repositories" page - // if there is not a profile readme, the overview tab should be treated as the repositories tab + // Default to overview page for users tab := ctx.FormString("tab") - if tab == "" || tab == "overview" { - if profileReadme != nil { - tab = "overview" - } else { - tab = "repositories" - } + if tab == "" { + tab = "overview" } ctx.Data["TabName"] = tab ctx.Data["HasUserProfileReadme"] = profileReadme != nil @@ -252,16 +247,35 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R total = int(count) case "overview": - if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { - log.Error("failed to GetBlobContent: %v", err) + // Load pinned repositories + pinnedRepos, err := user_model.GetUserPinnedRepos(ctx, ctx.ContextUser.ID) + if err != nil { + log.Error("GetUserPinnedRepos: %v", err) } else { - rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{ - CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), - }) - if profileContent, err := markdown.RenderString(rctx, bytes); err != nil { - log.Error("failed to RenderString: %v", err) + // Load repo details for each pinned repo + for _, p := range pinnedRepos { + repo, err := repo_model.GetRepositoryByID(ctx, p.RepoID) + if err == nil { + p.Repo = repo + } + } + } + ctx.Data["UserPinnedRepos"] = pinnedRepos + ctx.Data["IsContextUserProfile"] = ctx.Doer != nil && ctx.Doer.ID == ctx.ContextUser.ID + + // Load profile README + if profileReadme != nil { + if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { + log.Error("failed to GetBlobContent: %v", err) } else { - ctx.Data["ProfileReadmeContent"] = profileContent + rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{ + CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), + }) + if profileContent, err := markdown.RenderString(rctx, bytes); err != nil { + log.Error("failed to RenderString: %v", err) + } else { + ctx.Data["ProfileReadmeContent"] = profileContent + } } } case "organizations": diff --git a/routers/web/web.go b/routers/web/web.go index ac3c315d77..df2f9518fa 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1710,6 +1710,8 @@ func registerWebRoutes(m *web.Router) { m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar) m.Post("/action/{action:watch|unwatch}", reqSignIn, repo.ActionWatch) m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer) + m.Get("/action/pin", reqSignIn, repo.Pin) + m.Get("/action/unpin", reqSignIn, repo.Unpin) }, optSignIn, context.RepoAssignment) common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, repo.CorsHandler(), optSignInFromAnyOrigin) // "/{username}/{reponame}/{lfs-paths}": git-lfs support, see also addOwnerRepoGitHTTPRouters diff --git a/services/context/repo.go b/services/context/repo.go index 3813335374..af8a6edf7f 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -19,6 +19,7 @@ import ( git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" access_model "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -415,6 +416,16 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) { ctx.Repo.Repository = repo ctx.Data["RepoName"] = ctx.Repo.Repository.Name + + // Check if repo is pinned (for pin dropdown) + if ctx.Doer != nil { + isPinnedToUser, _ := user_model.IsRepoPinnedByUser(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + ctx.Data["IsRepoPinnedToUser"] = isPinnedToUser + } + if ctx.Repo.Repository.Owner.IsOrganization() { + isPinnedToOrg, _ := organization.IsRepoPinned(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID) + ctx.Data["IsRepoPinnedToOrg"] = isPinnedToOrg + } ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty } diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index a65d9b14f4..d105bcc790 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -65,6 +65,7 @@ {{if not $.DisableStars}} {{template "repo/star_unstar" $}} {{end}} + {{template "repo/pin_unpin" $}} {{if and (not .IsEmpty) ($.Permission.CanRead ctx.Consts.RepoUnitTypeCode)}}
+{{end}} diff --git a/templates/user/overview/header.tmpl b/templates/user/overview/header.tmpl index f4664c704d..d438513e7c 100644 --- a/templates/user/overview/header.tmpl +++ b/templates/user/overview/header.tmpl @@ -1,6 +1,6 @@