feat(repo): add cross-promotion feature for repositories
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 2m44s
Build and Release / Lint (push) Successful in 5m27s
Build and Release / Unit Tests (push) Successful in 5m39s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m58s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 8h4m56s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 6m27s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 8m19s
Build and Release / Build Binary (linux/arm64) (push) Successful in 8m25s
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 2m44s
Build and Release / Lint (push) Successful in 5m27s
Build and Release / Unit Tests (push) Successful in 5m39s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m58s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 8h4m56s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 6m27s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 8m19s
Build and Release / Build Binary (linux/arm64) (push) Successful in 8m25s
Add ability for repository owners to cross-promote up to 6 related repositories in the sidebar. Create repo_cross_promote table with migration v344 to store source-target relationships with display order. Add settings UI for managing promoted repos with drag-and-drop reordering. Display promoted repos in home sidebar with repository cards. Include locale strings and routing for cross-promotion management.
This commit is contained in:
@@ -418,6 +418,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(341, "Add hide_dotfiles to repository", v1_26.AddHideDotfilesToRepository),
|
||||
newMigration(342, "Add social_card_theme to repository", v1_26.AddSocialCardThemeToRepository),
|
||||
newMigration(343, "Add social card color, bg image, and unsplash author to repository", v1_26.AddSocialCardFieldsToRepository),
|
||||
newMigration(344, "Create repo_cross_promote table for cross-promoted repos", v1_26.CreateRepoCrossPromoteTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
23
models/migrations/v1_26/v344.go
Normal file
23
models/migrations/v1_26/v344.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"code.gitcaddy.com/server/v3/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// CreateRepoCrossPromoteTable creates the repo_cross_promote table.
|
||||
func CreateRepoCrossPromoteTable(x *xorm.Engine) error {
|
||||
type RepoCrossPromote struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
SourceRepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
TargetRepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
DisplayOrder int `xorm:"DEFAULT 0"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
}
|
||||
|
||||
return x.Sync(new(RepoCrossPromote))
|
||||
}
|
||||
146
models/repo/repo_cross_promote.go
Normal file
146
models/repo/repo_cross_promote.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
"code.gitcaddy.com/server/v3/modules/timeutil"
|
||||
)
|
||||
|
||||
// MaxCrossPromotedRepos is the maximum number of cross-promoted repos per source repo.
|
||||
const MaxCrossPromotedRepos = 6
|
||||
|
||||
// RepoCrossPromote represents a cross-promoted repository link.
|
||||
type RepoCrossPromote struct { //revive:disable-line:exported
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
SourceRepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
TargetRepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
DisplayOrder int `xorm:"DEFAULT 0"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
|
||||
TargetRepo *Repository `xorm:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for RepoCrossPromote.
|
||||
func (r *RepoCrossPromote) TableName() string {
|
||||
return "repo_cross_promote"
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(RepoCrossPromote))
|
||||
}
|
||||
|
||||
// CrossPromoteOrder represents the order for a cross-promoted repo.
|
||||
type CrossPromoteOrder struct {
|
||||
TargetRepoID int64
|
||||
DisplayOrder int
|
||||
}
|
||||
|
||||
// GetCrossPromotedRepos returns all cross-promoted repos for a source repo, ordered by display_order.
|
||||
func GetCrossPromotedRepos(ctx context.Context, sourceRepoID int64) ([]*RepoCrossPromote, error) {
|
||||
records := make([]*RepoCrossPromote, 0, MaxCrossPromotedRepos)
|
||||
return records, db.GetEngine(ctx).
|
||||
Where("source_repo_id = ?", sourceRepoID).
|
||||
OrderBy("display_order ASC, id ASC").
|
||||
Find(&records)
|
||||
}
|
||||
|
||||
// IsCrossPromoted checks if a target repo is already cross-promoted from a source repo.
|
||||
func IsCrossPromoted(ctx context.Context, sourceRepoID, targetRepoID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).
|
||||
Where("source_repo_id = ? AND target_repo_id = ?", sourceRepoID, targetRepoID).
|
||||
Exist(new(RepoCrossPromote))
|
||||
}
|
||||
|
||||
// CountCrossPromotedRepos returns the number of cross-promoted repos for a source repo.
|
||||
func CountCrossPromotedRepos(ctx context.Context, sourceRepoID int64) (int64, error) {
|
||||
return db.GetEngine(ctx).
|
||||
Where("source_repo_id = ?", sourceRepoID).
|
||||
Count(new(RepoCrossPromote))
|
||||
}
|
||||
|
||||
// CreateCrossPromote adds a cross-promoted repo link.
|
||||
func CreateCrossPromote(ctx context.Context, record *RepoCrossPromote) error {
|
||||
exists, err := IsCrossPromoted(ctx, record.SourceRepoID, record.TargetRepoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return ErrCrossPromoteAlreadyExist{SourceRepoID: record.SourceRepoID, TargetRepoID: record.TargetRepoID}
|
||||
}
|
||||
|
||||
count, err := CountCrossPromotedRepos(ctx, record.SourceRepoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= MaxCrossPromotedRepos {
|
||||
return ErrCrossPromoteMaxReached{SourceRepoID: record.SourceRepoID}
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).Insert(record)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCrossPromote removes a cross-promoted repo link.
|
||||
func DeleteCrossPromote(ctx context.Context, sourceRepoID, targetRepoID int64) error {
|
||||
_, err := db.GetEngine(ctx).
|
||||
Where("source_repo_id = ? AND target_repo_id = ?", sourceRepoID, targetRepoID).
|
||||
Delete(new(RepoCrossPromote))
|
||||
return err
|
||||
}
|
||||
|
||||
// ReorderCrossPromotedRepos updates the display order of cross-promoted repos in a transaction.
|
||||
func ReorderCrossPromotedRepos(ctx context.Context, sourceRepoID int64, orders []CrossPromoteOrder) error {
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
for _, order := range orders {
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("source_repo_id = ? AND target_repo_id = ?", sourceRepoID, order.TargetRepoID).
|
||||
MustCols("display_order").
|
||||
Update(&RepoCrossPromote{
|
||||
DisplayOrder: order.DisplayOrder,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// ErrCrossPromoteAlreadyExist represents a duplicate cross-promote error.
|
||||
type ErrCrossPromoteAlreadyExist struct {
|
||||
SourceRepoID int64
|
||||
TargetRepoID int64
|
||||
}
|
||||
|
||||
func (err ErrCrossPromoteAlreadyExist) Error() string {
|
||||
return "repository is already cross-promoted"
|
||||
}
|
||||
|
||||
// IsErrCrossPromoteAlreadyExist checks if err is ErrCrossPromoteAlreadyExist.
|
||||
func IsErrCrossPromoteAlreadyExist(err error) bool {
|
||||
_, ok := err.(ErrCrossPromoteAlreadyExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
// ErrCrossPromoteMaxReached represents a max limit reached error.
|
||||
type ErrCrossPromoteMaxReached struct {
|
||||
SourceRepoID int64
|
||||
}
|
||||
|
||||
func (err ErrCrossPromoteMaxReached) Error() string {
|
||||
return "maximum cross-promoted repositories reached"
|
||||
}
|
||||
|
||||
// IsErrCrossPromoteMaxReached checks if err is ErrCrossPromoteMaxReached.
|
||||
func IsErrCrossPromoteMaxReached(err error) bool {
|
||||
_, ok := err.(ErrCrossPromoteMaxReached)
|
||||
return ok
|
||||
}
|
||||
@@ -353,6 +353,13 @@ func (r *Renderer) renderSolidCard(data CardData) ([]byte, error) {
|
||||
repoW := measureText(data.RepoFullName, faces.meta)
|
||||
drawText(img, (w-repoW)/2, metaY, data.RepoFullName, faces.meta, theme.SubtextColor)
|
||||
|
||||
// Unsplash attribution (centered, below repo name)
|
||||
if data.UnsplashAuthor != "" {
|
||||
attrText := "Photo by " + data.UnsplashAuthor
|
||||
attrW := measureText(attrText, faces.meta)
|
||||
drawText(img, (w-attrW)/2, metaY+30, attrText, faces.meta, theme.SubtextColor)
|
||||
}
|
||||
|
||||
// GitCaddy logo (bottom right)
|
||||
logoBounds := r.logo.Bounds()
|
||||
logoX := w - pad - logoBounds.Dx()
|
||||
@@ -400,17 +407,17 @@ func (r *Renderer) renderImageCard(data CardData) ([]byte, error) {
|
||||
// Build text from bottom up
|
||||
yBottom := h - bottomPad
|
||||
|
||||
// Repo full name (bottom-most, small caps style)
|
||||
drawText(img, pad, yBottom, data.RepoFullName, faces.meta, subtextColor)
|
||||
yBottom -= 36
|
||||
|
||||
// Unsplash attribution (right-aligned on same line as repo name)
|
||||
// Unsplash attribution (very bottom line)
|
||||
if data.UnsplashAuthor != "" {
|
||||
attrText := "Photo by " + data.UnsplashAuthor
|
||||
attrX := w - pad - measureText(attrText, faces.meta)
|
||||
drawText(img, attrX, yBottom+36, attrText, faces.meta, subtextColor)
|
||||
drawText(img, pad, yBottom, attrText, faces.meta, subtextColor)
|
||||
yBottom -= 30
|
||||
}
|
||||
|
||||
// Repo full name
|
||||
drawText(img, pad, yBottom, data.RepoFullName, faces.meta, subtextColor)
|
||||
yBottom -= 36
|
||||
|
||||
// Language indicator
|
||||
if data.LanguageName != "" {
|
||||
langColor := parseHexColor(data.LanguageColor)
|
||||
|
||||
@@ -4072,6 +4072,22 @@
|
||||
"repo.settings.media_kit.unsplash_color_blue": "Blue",
|
||||
"repo.settings.media_kit.unsplash_attribution": "Photo by %s on Unsplash",
|
||||
"repo.settings.media_kit.preview": "Preview",
|
||||
"repo.settings.cross_promote": "Cross Promote",
|
||||
"repo.settings.cross_promote.description": "Select repositories to cross-promote in this repository's sidebar.",
|
||||
"repo.settings.cross_promote.drag_hint": "Drag to reorder",
|
||||
"repo.settings.cross_promote.save_order": "Save Order",
|
||||
"repo.settings.cross_promote.order_saved": "Order saved",
|
||||
"repo.settings.cross_promote.add_repo": "Add Repository",
|
||||
"repo.settings.cross_promote.repo_name_label": "Repository (owner/name)",
|
||||
"repo.settings.cross_promote.add": "Add",
|
||||
"repo.settings.cross_promote.added": "Repository added",
|
||||
"repo.settings.cross_promote.removed": "Repository removed",
|
||||
"repo.settings.cross_promote.already_exists": "Already cross-promoted",
|
||||
"repo.settings.cross_promote.max_reached": "Maximum of 6 repositories reached",
|
||||
"repo.settings.cross_promote.invalid_repo": "Repository not found",
|
||||
"repo.settings.cross_promote.self_promote": "Cannot promote current repository",
|
||||
"repo.settings.cross_promote.empty": "No cross-promoted repositories yet",
|
||||
"repo.cross_promoted": "Also Check Out",
|
||||
"repo.settings.license": "License",
|
||||
"repo.settings.license_type": "License Type",
|
||||
"repo.settings.license_none": "No license selected",
|
||||
|
||||
169
routers/web/repo/setting/cross_promote.go
Normal file
169
routers/web/repo/setting/cross_promote.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
"code.gitcaddy.com/server/v3/modules/templates"
|
||||
"code.gitcaddy.com/server/v3/services/context"
|
||||
)
|
||||
|
||||
const tplCrossPromote templates.TplName = "repo/settings/cross_promote"
|
||||
|
||||
// CrossPromote renders the cross-promote settings page.
|
||||
func CrossPromote(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.cross_promote")
|
||||
ctx.Data["PageIsSettingsCrossPromote"] = true
|
||||
|
||||
records, err := repo_model.GetCrossPromotedRepos(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCrossPromotedRepos", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Load target repo details
|
||||
repoIDs := make([]int64, len(records))
|
||||
for i, r := range records {
|
||||
repoIDs[i] = r.TargetRepoID
|
||||
}
|
||||
|
||||
repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRepositoriesMapByIDs", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range records {
|
||||
if repo, ok := repos[r.TargetRepoID]; ok {
|
||||
r.TargetRepo = repo
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["CrossPromotedRepos"] = records
|
||||
ctx.HTML(http.StatusOK, tplCrossPromote)
|
||||
}
|
||||
|
||||
// CrossPromotePost saves the reordered cross-promoted repos.
|
||||
func CrossPromotePost(ctx *context.Context) {
|
||||
repoIDsStr := ctx.FormString("repo_ids_ordered")
|
||||
if repoIDsStr == "" {
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.cross_promote.order_saved"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/cross_promote")
|
||||
return
|
||||
}
|
||||
|
||||
orders := make([]repo_model.CrossPromoteOrder, 0)
|
||||
for i, idStr := range strings.Split(repoIDsStr, ",") {
|
||||
idStr = strings.TrimSpace(idStr)
|
||||
if idStr == "" {
|
||||
continue
|
||||
}
|
||||
var targetRepoID int64
|
||||
if _, err := fmt.Sscanf(idStr, "%d", &targetRepoID); err != nil || targetRepoID == 0 {
|
||||
continue
|
||||
}
|
||||
orders = append(orders, repo_model.CrossPromoteOrder{
|
||||
TargetRepoID: targetRepoID,
|
||||
DisplayOrder: i,
|
||||
})
|
||||
}
|
||||
|
||||
if err := repo_model.ReorderCrossPromotedRepos(ctx, ctx.Repo.Repository.ID, orders); err != nil {
|
||||
ctx.ServerError("ReorderCrossPromotedRepos", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.cross_promote.order_saved"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/cross_promote")
|
||||
}
|
||||
|
||||
// CrossPromoteAdd adds a cross-promoted repo.
|
||||
func CrossPromoteAdd(ctx *context.Context) {
|
||||
repoName := strings.TrimSpace(ctx.FormString("repo_name"))
|
||||
|
||||
if repoName == "" {
|
||||
ctx.Flash.Error(ctx.Tr("repo.settings.cross_promote.invalid_repo"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/cross_promote")
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(repoName, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
ctx.Flash.Error(ctx.Tr("repo.settings.cross_promote.invalid_repo"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/cross_promote")
|
||||
return
|
||||
}
|
||||
|
||||
targetRepo, err := repo_model.GetRepositoryByOwnerAndName(ctx, parts[0], parts[1])
|
||||
if err != nil {
|
||||
ctx.Flash.Error(ctx.Tr("repo.settings.cross_promote.invalid_repo"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/cross_promote")
|
||||
return
|
||||
}
|
||||
|
||||
// Cannot promote self
|
||||
if targetRepo.ID == ctx.Repo.Repository.ID {
|
||||
ctx.Flash.Error(ctx.Tr("repo.settings.cross_promote.self_promote"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/cross_promote")
|
||||
return
|
||||
}
|
||||
|
||||
// Get current max display order
|
||||
records, err := repo_model.GetCrossPromotedRepos(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCrossPromotedRepos", err)
|
||||
return
|
||||
}
|
||||
|
||||
maxOrder := 0
|
||||
for _, r := range records {
|
||||
if r.DisplayOrder > maxOrder {
|
||||
maxOrder = r.DisplayOrder
|
||||
}
|
||||
}
|
||||
|
||||
record := &repo_model.RepoCrossPromote{
|
||||
SourceRepoID: ctx.Repo.Repository.ID,
|
||||
TargetRepoID: targetRepo.ID,
|
||||
DisplayOrder: maxOrder + 1,
|
||||
}
|
||||
|
||||
if err := repo_model.CreateCrossPromote(ctx, record); err != nil {
|
||||
if repo_model.IsErrCrossPromoteAlreadyExist(err) {
|
||||
ctx.Flash.Warning(ctx.Tr("repo.settings.cross_promote.already_exists"))
|
||||
} else if repo_model.IsErrCrossPromoteMaxReached(err) {
|
||||
ctx.Flash.Error(ctx.Tr("repo.settings.cross_promote.max_reached"))
|
||||
} else {
|
||||
ctx.ServerError("CreateCrossPromote", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.cross_promote.added"))
|
||||
}
|
||||
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/cross_promote")
|
||||
}
|
||||
|
||||
// CrossPromoteRemove removes a cross-promoted repo.
|
||||
func CrossPromoteRemove(ctx *context.Context) {
|
||||
targetRepoID := ctx.FormInt64("target_repo_id")
|
||||
|
||||
if targetRepoID == 0 {
|
||||
ctx.Flash.Error(ctx.Tr("repo.settings.cross_promote.invalid_repo"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/cross_promote")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo_model.DeleteCrossPromote(ctx, ctx.Repo.Repository.ID, targetRepoID); err != nil {
|
||||
ctx.ServerError("DeleteCrossPromote", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.cross_promote.removed"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/cross_promote")
|
||||
}
|
||||
@@ -370,6 +370,47 @@ func prepareHomeSidebarLatestRelease(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func prepareHomeSidebarCrossPromotedRepos(ctx *context.Context) {
|
||||
records, err := repo_model.GetCrossPromotedRepos(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCrossPromotedRepos", err)
|
||||
return
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
repoIDs := make([]int64, len(records))
|
||||
for i, r := range records {
|
||||
repoIDs[i] = r.TargetRepoID
|
||||
}
|
||||
|
||||
repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRepositoriesMapByIDs", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter to repos the viewer can access
|
||||
visible := make([]*repo_model.RepoCrossPromote, 0, len(records))
|
||||
for _, r := range records {
|
||||
repo, ok := repos[r.TargetRepoID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Public and limited repos are visible to everyone; private repos require login
|
||||
if repo.IsPrivate && (ctx.Doer == nil || (ctx.Doer.ID != repo.OwnerID && !ctx.Doer.IsAdmin)) {
|
||||
continue
|
||||
}
|
||||
r.TargetRepo = repo
|
||||
visible = append(visible, r)
|
||||
}
|
||||
|
||||
if len(visible) > 0 {
|
||||
ctx.Data["CrossPromotedRepos"] = visible
|
||||
}
|
||||
}
|
||||
|
||||
func prepareUpstreamDivergingInfo(ctx *context.Context) {
|
||||
if !ctx.Repo.Repository.IsFork || !ctx.Repo.RefFullName.IsBranch() || ctx.Repo.TreePath != "" {
|
||||
return
|
||||
@@ -657,6 +698,7 @@ func Home(ctx *context.Context) {
|
||||
prepareHomeGalleryTab,
|
||||
prepareHomeSidebarCitationFile(entry),
|
||||
prepareHomeSidebarLanguageStats,
|
||||
prepareHomeSidebarCrossPromotedRepos,
|
||||
prepareHomeSidebarLatestRelease,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1203,6 +1203,12 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Post("/unsplash/select", repo_setting.MediaKitUnsplashSelect)
|
||||
})
|
||||
|
||||
m.Group("/cross_promote", func() {
|
||||
m.Combo("").Get(repo_setting.CrossPromote).Post(repo_setting.CrossPromotePost)
|
||||
m.Post("/add", repo_setting.CrossPromoteAdd)
|
||||
m.Post("/remove", repo_setting.CrossPromoteRemove)
|
||||
})
|
||||
|
||||
m.Group("/hidden_folders", func() {
|
||||
m.Get("", repo_setting.HiddenFolders)
|
||||
m.Post("", repo_setting.HiddenFoldersPost)
|
||||
|
||||
@@ -57,5 +57,27 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .CrossPromotedRepos}}
|
||||
<div class="flex-item">
|
||||
<div class="flex-item-main">
|
||||
<div class="flex-item-title">
|
||||
{{ctx.Locale.Tr "repo.cross_promoted"}}
|
||||
</div>
|
||||
<div class="flex-item-body">
|
||||
{{range .CrossPromotedRepos}}
|
||||
<a class="tw-flex tw-items-center tw-gap-2 tw-py-1 muted" href="{{.TargetRepo.Link}}">
|
||||
<img class="tw-rounded" style="width:24px;height:24px;object-fit:contain;" src="{{.TargetRepo.RelAvatarLink ctx}}" alt="">
|
||||
<div class="tw-flex-1 tw-min-w-0">
|
||||
<div class="tw-font-semibold gt-ellipsis">{{.TargetRepo.FullName}}</div>
|
||||
{{if .TargetRepo.Description}}
|
||||
<div class="tw-text-xs tw-text-grey gt-ellipsis">{{.TargetRepo.Description}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
179
templates/repo/settings/cross_promote.tmpl
Normal file
179
templates/repo/settings/cross_promote.tmpl
Normal file
@@ -0,0 +1,179 @@
|
||||
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings cross-promote")}}
|
||||
<div class="repo-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "repo.settings.cross_promote"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p class="text grey">{{ctx.Locale.Tr "repo.settings.cross_promote.description"}}</p>
|
||||
|
||||
{{if .CrossPromotedRepos}}
|
||||
<form id="cross-promote-order-form" method="post" action="{{.RepoLink}}/settings/cross_promote">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" id="repo-ids-ordered" name="repo_ids_ordered" value="">
|
||||
<div class="ui info message">
|
||||
<p>{{svg "octicon-info" 16}} {{ctx.Locale.Tr "repo.settings.cross_promote.drag_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<div id="cross-promote-list" class="ui segment">
|
||||
{{range .CrossPromotedRepos}}
|
||||
<div class="ui segment cross-promote-item tw-flex tw-items-center tw-gap-3 tw-cursor-move" data-repo-id="{{.TargetRepoID}}" draggable="true">
|
||||
<span class="drag-handle tw-text-grey">{{svg "octicon-grabber" 20}}</span>
|
||||
{{if .TargetRepo}}
|
||||
<img class="tw-rounded" style="width:24px;height:24px;object-fit:contain;" src="{{.TargetRepo.RelAvatarLink ctx}}" alt="">
|
||||
<span class="tw-flex-1 tw-min-w-0">
|
||||
<span class="tw-font-semibold">{{.TargetRepo.FullName}}</span>
|
||||
{{if .TargetRepo.Description}}
|
||||
<span class="tw-text-xs tw-text-grey tw-ml-2">{{.TargetRepo.Description}}</span>
|
||||
{{end}}
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="tw-flex-1">Unknown repo (ID: {{.TargetRepoID}})</span>
|
||||
{{end}}
|
||||
<button type="button" class="ui tiny red icon button remove-btn" data-target-repo-id="{{.TargetRepoID}}" title="{{ctx.Locale.Tr "remove"}}">
|
||||
{{svg "octicon-x" 14}}
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="tw-mt-4">
|
||||
<button type="submit" class="ui primary button">
|
||||
{{svg "octicon-check" 16}} {{ctx.Locale.Tr "repo.settings.cross_promote.save_order"}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="ui placeholder segment">
|
||||
<div class="ui icon header">
|
||||
{{svg "octicon-megaphone" 48}}
|
||||
<div class="content">
|
||||
{{ctx.Locale.Tr "repo.settings.cross_promote.empty"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h5 class="ui header">{{ctx.Locale.Tr "repo.settings.cross_promote.add_repo"}}</h5>
|
||||
<form class="ui form" method="post" action="{{.RepoLink}}/settings/cross_promote/add">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.cross_promote.repo_name_label"}}</label>
|
||||
<input type="text" name="repo_name" placeholder="owner/reponame" autocomplete="off">
|
||||
</div>
|
||||
<button type="submit" class="ui primary button">
|
||||
{{svg "octicon-plus" 16}} {{ctx.Locale.Tr "repo.settings.cross_promote.add"}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const list = document.getElementById('cross-promote-list');
|
||||
if (!list) return;
|
||||
|
||||
let draggedItem = null;
|
||||
|
||||
list.querySelectorAll('.cross-promote-item').forEach(item => {
|
||||
item.addEventListener('dragstart', function(e) {
|
||||
draggedItem = this;
|
||||
this.classList.add('tw-opacity-50');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
item.addEventListener('dragend', function() {
|
||||
this.classList.remove('tw-opacity-50');
|
||||
draggedItem = null;
|
||||
list.querySelectorAll('.cross-promote-item').forEach(i => i.classList.remove('drag-over'));
|
||||
});
|
||||
|
||||
item.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (this !== draggedItem) {
|
||||
this.classList.add('drag-over');
|
||||
}
|
||||
});
|
||||
|
||||
item.addEventListener('dragleave', function() {
|
||||
this.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
item.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.classList.remove('drag-over');
|
||||
if (this !== draggedItem && draggedItem) {
|
||||
const items = Array.from(list.querySelectorAll('.cross-promote-item'));
|
||||
const draggedIdx = items.indexOf(draggedItem);
|
||||
const targetIdx = items.indexOf(this);
|
||||
|
||||
if (draggedIdx < targetIdx) {
|
||||
this.parentNode.insertBefore(draggedItem, this.nextSibling);
|
||||
} else {
|
||||
this.parentNode.insertBefore(draggedItem, this);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Form submit - collect repo IDs in current DOM order
|
||||
const form = document.getElementById('cross-promote-order-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function() {
|
||||
const items = list.querySelectorAll('.cross-promote-item');
|
||||
const ids = Array.from(items).map(item => item.dataset.repoId);
|
||||
document.getElementById('repo-ids-ordered').value = ids.join(',');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove buttons
|
||||
document.querySelectorAll('.remove-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const targetRepoId = this.dataset.targetRepoId;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{{.RepoLink}}/settings/cross_promote/remove';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'target_repo_id';
|
||||
input.value = targetRepoId;
|
||||
form.appendChild(input);
|
||||
|
||||
const csrf = document.querySelector('meta[name=_csrf]');
|
||||
if (csrf) {
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = '_csrf';
|
||||
csrfInput.value = csrf.content;
|
||||
form.appendChild(csrfInput);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.cross-promote-item {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.cross-promote-item:hover {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
.cross-promote-item.drag-over {
|
||||
border-top: 2px solid var(--color-primary);
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{template "repo/settings/layout_footer" .}}
|
||||
@@ -46,8 +46,8 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Image options (shown when "image" selected) -->
|
||||
<div id="image-options" style="{{if ne .Repository.SocialCardTheme "image"}}display:none{{end}}">
|
||||
<!-- Background image options (shown when "solid" or "image" selected) -->
|
||||
<div id="image-options" style="{{if and (ne .Repository.SocialCardTheme "image") (ne .Repository.SocialCardTheme "solid")}}display:none{{end}}">
|
||||
<div class="ui divider"></div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.media_kit.bg_image"}}</label>
|
||||
@@ -132,7 +132,6 @@
|
||||
<!-- Preview -->
|
||||
<div class="ui divider"></div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.media_kit.preview"}}</label>
|
||||
<img id="social-card-preview"
|
||||
src="{{AppSubUrl}}/{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview?theme={{.Repository.SocialCardTheme}}"
|
||||
alt="Social card preview"
|
||||
@@ -182,7 +181,7 @@
|
||||
r.addEventListener('change', function() {
|
||||
updateButtonStyles();
|
||||
solidOpts.style.display = this.value === 'solid' ? '' : 'none';
|
||||
imageOpts.style.display = this.value === 'image' ? '' : 'none';
|
||||
imageOpts.style.display = (this.value === 'image' || this.value === 'solid') ? '' : 'none';
|
||||
updatePreview();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
<a class="{{if .PageIsSettingsMediaKit}}active {{end}}item" href="{{.RepoLink}}/settings/media_kit">
|
||||
{{ctx.Locale.Tr "repo.settings.media_kit"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsCrossPromote}}active {{end}}item" href="{{.RepoLink}}/settings/cross_promote">
|
||||
{{ctx.Locale.Tr "repo.settings.cross_promote"}}
|
||||
</a>
|
||||
{{if or .Repository.IsPrivate .Permission.HasAnyUnitPublicAccess}}
|
||||
<a class="{{if .PageIsSettingsPublicAccess}}active {{end}}item" href="{{.RepoLink}}/settings/public_access">
|
||||
{{ctx.Locale.Tr "repo.settings.public_access"}}
|
||||
|
||||
Reference in New Issue
Block a user