From 8c979dde3680d48a24729a9995e3ad69d8d655bb Mon Sep 17 00:00:00 2001 From: logikonline Date: Thu, 22 Jan 2026 12:54:00 -0500 Subject: [PATCH] feat(org): add pinned repositories management UI Implements a settings page for organization admins to manage pinned repositories. Adds ability to pin/unpin repos, reorder them via drag-and-drop, and view currently pinned repos. Includes new routes, templates, locale strings, and helper function IsErrOrgPinnedRepoAlreadyExist for error handling. --- models/organization/org_pinned.go | 6 + options/locale/locale_en-US.json | 20 +++ routers/web/org/setting.go | 188 +++++++++++++++++++++++++++++ routers/web/web.go | 7 ++ templates/org/home.tmpl | 14 +-- templates/org/settings/navbar.tmpl | 3 + templates/org/settings/pinned.tmpl | 186 ++++++++++++++++++++++++++++ 7 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 templates/org/settings/pinned.tmpl diff --git a/models/organization/org_pinned.go b/models/organization/org_pinned.go index ed4ceca0e6..6bf78e22ad 100644 --- a/models/organization/org_pinned.go +++ b/models/organization/org_pinned.go @@ -271,3 +271,9 @@ type ErrOrgPinnedRepoAlreadyExist struct { func (err ErrOrgPinnedRepoAlreadyExist) Error() string { return "repository is already pinned" } + +// IsErrOrgPinnedRepoAlreadyExist checks if err is ErrOrgPinnedRepoAlreadyExist +func IsErrOrgPinnedRepoAlreadyExist(err error) bool { + _, ok := err.(ErrOrgPinnedRepoAlreadyExist) + return ok +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index d1adf85441..258a1d5f8d 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3864,8 +3864,28 @@ "git.filemode.submodule": "Submodule", "org.pinned_repos_empty_title": "Showcase your best work", "org.pinned_repos_empty_desc": "Pin up to 6 repositories to highlight your organization's most important projects.", + "org.settings.pinned": "Pinned Repos", "org.settings.pinned.manage": "Manage Pins", "org.settings.pinned.setup": "Set Up Pinned Repos", + "org.settings.pinned.title": "Pinned Repositories", + "org.settings.pinned.description": "Manage which repositories are pinned to your organization's homepage and their display order.", + "org.settings.pinned.repos": "Pinned Repositories", + "org.settings.pinned.drag_hint": "Drag and drop to reorder pinned repositories", + "org.settings.pinned.save_order": "Save Order", + "org.settings.pinned.order_saved": "Pinned repository order saved", + "org.settings.pinned.add_repo": "Add Repository", + "org.settings.pinned.select_repo": "Repository", + "org.settings.pinned.select_placeholder": "Select a repository to pin", + "org.settings.pinned.pin_repo": "Pin Repository", + "org.settings.pinned.repo_pinned": "Repository pinned successfully", + "org.settings.pinned.repo_unpinned": "Repository unpinned successfully", + "org.settings.pinned.unpin": "Unpin", + "org.settings.pinned.unpin_confirm": "Are you sure you want to unpin this repository?", + "org.settings.pinned.invalid_repo": "Invalid repository", + "org.settings.pinned.already_pinned": "This repository is already pinned", + "org.settings.pinned.unknown_repo": "Unknown repository", + "org.settings.pinned.empty": "No pinned repositories", + "org.settings.pinned.empty_hint": "Pin repositories from the repository settings page or add them below", "org.no_public_members": "No public members yet", "org.profile_readme_empty_title": "Add a profile README", "org.profile_readme_empty_desc": "Create a .profile repository with a README.md to introduce your organization.", diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index e13bdea746..9b75ae9077 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -5,10 +5,12 @@ package org import ( + "fmt" "net/http" "net/url" "code.gitcaddy.com/server/v3/models/db" + "code.gitcaddy.com/server/v3/models/organization" packages_model "code.gitcaddy.com/server/v3/models/packages" repo_model "code.gitcaddy.com/server/v3/models/repo" user_model "code.gitcaddy.com/server/v3/models/user" @@ -36,6 +38,8 @@ const ( tplSettingsHooks templates.TplName = "org/settings/hooks" // tplSettingsLabels template path for render labels settings tplSettingsLabels templates.TplName = "org/settings/labels" + // tplSettingsPinned template path for render pinned repos settings + tplSettingsPinned templates.TplName = "org/settings/pinned" ) // Settings render the main settings page @@ -265,3 +269,187 @@ func SettingsChangeVisibilityPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("org.settings.change_visibility_success", ctx.Org.Organization.Name)) ctx.JSONRedirect(setting.AppSubURL + "/org/" + url.PathEscape(ctx.Org.Organization.Name) + "/settings") } + +// SettingsPinned renders the pinned repos management page +func SettingsPinned(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings.pinned.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPinned"] = true + + org := ctx.Org.Organization + + // Get pinned repos + pinnedRepos, err := organization.GetOrgPinnedRepos(ctx, org.ID) + if err != nil { + ctx.ServerError("GetOrgPinnedRepos", err) + return + } + + // Load repo details + repoIDs := make([]int64, len(pinnedRepos)) + for i, p := range pinnedRepos { + repoIDs[i] = p.RepoID + } + + repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs) + if err != nil { + ctx.ServerError("GetRepositoriesMapByIDs", err) + return + } + + for _, p := range pinnedRepos { + if repo, ok := repos[p.RepoID]; ok { + p.Repo = repo + } + } + + // Load groups + if err := organization.LoadPinnedRepoGroups(ctx, pinnedRepos, org.ID); err != nil { + ctx.ServerError("LoadPinnedRepoGroups", err) + return + } + + ctx.Data["PinnedRepos"] = pinnedRepos + + // Get available repos (not yet pinned) + pinnedRepoIDs, err := organization.GetOrgPinnedRepoIDs(ctx, org.ID) + if err != nil { + ctx.ServerError("GetOrgPinnedRepoIDs", err) + return + } + + pinnedSet := make(map[int64]bool) + for _, id := range pinnedRepoIDs { + pinnedSet[id] = true + } + + allRepos, _, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{ + Actor: ctx.Doer, + OwnerID: org.ID, + Private: true, + }) + if err != nil { + ctx.ServerError("GetUserRepositories", err) + return + } + + availableRepos := make([]*repo_model.Repository, 0) + for _, repo := range allRepos { + if !pinnedSet[repo.ID] { + availableRepos = append(availableRepos, repo) + } + } + ctx.Data["AvailableRepos"] = availableRepos + + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } + + ctx.HTML(http.StatusOK, tplSettingsPinned) +} + +// SettingsPinnedPost saves the pinned repos order +func SettingsPinnedPost(ctx *context.Context) { + org := ctx.Org.Organization + repoIDs := ctx.FormStrings("repo_ids") + + orders := make([]organization.PinnedRepoOrder, 0, len(repoIDs)) + for i, idStr := range repoIDs { + var repoID int64 + if _, err := fmt.Sscanf(idStr, "%d", &repoID); err != nil || repoID == 0 { + continue + } + orders = append(orders, organization.PinnedRepoOrder{ + RepoID: repoID, + DisplayOrder: i, + }) + } + + if err := organization.ReorderOrgPinnedRepos(ctx, org.ID, orders); err != nil { + ctx.ServerError("ReorderOrgPinnedRepos", err) + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.pinned.order_saved")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/pinned") +} + +// SettingsPinnedAdd adds a repo to pinned repos +func SettingsPinnedAdd(ctx *context.Context) { + org := ctx.Org.Organization + repoID := ctx.FormInt64("repo_id") + + if repoID == 0 { + ctx.Flash.Error(ctx.Tr("org.settings.pinned.invalid_repo")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/pinned") + return + } + + // Verify repo belongs to org + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + ctx.Flash.Error(ctx.Tr("org.settings.pinned.invalid_repo")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/pinned") + return + } + + if repo.OwnerID != org.ID { + ctx.Flash.Error(ctx.Tr("org.settings.pinned.invalid_repo")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/pinned") + return + } + + // Get max display order + pinnedRepos, err := organization.GetOrgPinnedRepos(ctx, org.ID) + if err != nil { + ctx.ServerError("GetOrgPinnedRepos", err) + return + } + + maxOrder := 0 + for _, p := range pinnedRepos { + if p.DisplayOrder > maxOrder { + maxOrder = p.DisplayOrder + } + } + + pinned := &organization.OrgPinnedRepo{ + OrgID: org.ID, + RepoID: repoID, + DisplayOrder: maxOrder + 1, + } + + if err := organization.CreateOrgPinnedRepo(ctx, pinned); err != nil { + if organization.IsErrOrgPinnedRepoAlreadyExist(err) { + ctx.Flash.Warning(ctx.Tr("org.settings.pinned.already_pinned")) + } else { + ctx.ServerError("CreateOrgPinnedRepo", err) + return + } + } else { + ctx.Flash.Success(ctx.Tr("org.settings.pinned.repo_pinned")) + } + + ctx.Redirect(ctx.Org.OrgLink + "/settings/pinned") +} + +// SettingsPinnedRemove removes a repo from pinned repos +func SettingsPinnedRemove(ctx *context.Context) { + org := ctx.Org.Organization + repoID := ctx.FormInt64("repo_id") + + if repoID == 0 { + ctx.Flash.Error(ctx.Tr("org.settings.pinned.invalid_repo")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/pinned") + return + } + + if err := organization.DeleteOrgPinnedRepo(ctx, org.ID, repoID); err != nil { + ctx.ServerError("DeleteOrgPinnedRepo", err) + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.pinned.repo_unpinned")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/pinned") +} diff --git a/routers/web/web.go b/routers/web/web.go index ca742588e5..45e037a5ed 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1040,6 +1040,13 @@ func registerWebRoutes(m *web.Router) { m.Get("/scan", org.SettingsLicenseScan) }) + m.Group("/pinned", func() { + m.Get("", org.SettingsPinned) + m.Post("", org.SettingsPinnedPost) + m.Post("/add", org.SettingsPinnedAdd) + m.Post("/remove", org.SettingsPinnedRemove) + }) + m.Group("/actions", func() { m.Get("", org_setting.RedirectToDefaultSetting) addSettingsRunnersRoutes() diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index b0370e5c15..e95ec207fe 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -53,11 +53,8 @@ {{.Repo.PrimaryLanguage.Language}} {{end}} - {{if .Repo.NumStars}} - {{svg "octicon-star" 14}} {{.Repo.NumStars}} - {{end}} - {{if .Repo.NumForks}} - {{svg "octicon-repo-forked" 14}} {{.Repo.NumForks}} + {{if .Repo.LicenseType}} + {{svg "octicon-law" 14}} {{.Repo.LicenseType}} {{end}} @@ -101,11 +98,8 @@ {{.Repo.PrimaryLanguage.Language}} {{end}} - {{if .Repo.NumStars}} - {{svg "octicon-star" 14}} {{.Repo.NumStars}} - {{end}} - {{if .Repo.NumForks}} - {{svg "octicon-repo-forked" 14}} {{.Repo.NumForks}} + {{if .Repo.LicenseType}} + {{svg "octicon-law" 14}} {{.Repo.LicenseType}} {{end}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 6aaffee48d..8aa2a35e66 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -15,6 +15,9 @@ {{ctx.Locale.Tr "org.settings.license"}} + + {{ctx.Locale.Tr "org.settings.pinned"}} + {{if .EnableOAuth2}} {{ctx.Locale.Tr "settings.applications"}} diff --git a/templates/org/settings/pinned.tmpl b/templates/org/settings/pinned.tmpl new file mode 100644 index 0000000000..903e33a8df --- /dev/null +++ b/templates/org/settings/pinned.tmpl @@ -0,0 +1,186 @@ +{{template "org/settings/layout_head" .}} +
+

+ {{ctx.Locale.Tr "org.settings.pinned.title"}} +

+
+

{{ctx.Locale.Tr "org.settings.pinned.description"}}

+ + {{if .PinnedRepos}} +
+
{{ctx.Locale.Tr "org.settings.pinned.repos"}}
+
+

{{svg "octicon-info" 16}} {{ctx.Locale.Tr "org.settings.pinned.drag_hint"}}

+
+ +
+ {{range $index, $pinned := .PinnedRepos}} +
+ {{svg "octicon-grabber" 20}} + + {{if $pinned.Repo}} + {{if $pinned.Repo.Avatar}} + + {{else}} + {{svg "octicon-repo" 16}} + {{end}} + {{$pinned.Repo.Name}} + {{if $pinned.Repo.IsPrivate}} + {{ctx.Locale.Tr "repo.desc.private"}} + {{end}} + {{else}} + {{ctx.Locale.Tr "org.settings.pinned.unknown_repo"}} (ID: {{$pinned.RepoID}}) + {{end}} + + {{if $pinned.Group}} + {{$pinned.Group.Name}} + {{end}} + + +
+ {{end}} +
+ +
+ +
+
+ {{else}} +
+
+ {{svg "octicon-pin" 48}} +
+ {{ctx.Locale.Tr "org.settings.pinned.empty"}} +
+
+

{{ctx.Locale.Tr "org.settings.pinned.empty_hint"}}

+
+ {{end}} + +
+ +
{{ctx.Locale.Tr "org.settings.pinned.add_repo"}}
+
+
+ + +
+ +
+
+
+ + + + + +{{template "org/settings/layout_footer" .}}