2
0

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

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:
2026-01-31 08:36:23 -05:00
parent 606f7cb474
commit b1b308d766
12 changed files with 624 additions and 11 deletions

View File

@@ -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
}

View 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))
}

View 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
}

View File

@@ -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)

View File

@@ -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",

View 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")
}

View File

@@ -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,
)
}

View File

@@ -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)

View File

@@ -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>

View 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" .}}

View File

@@ -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();
});
});

View File

@@ -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"}}