2
0

feat(packages): add bulk visibility management for packages
Some checks failed
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m12s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m8s
Build and Release / Lint (push) Successful in 5m19s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m11s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 5m38s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h5m9s
Build and Release / Build Binary (linux/arm64) (push) Successful in 8m29s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Failing after 10m38s

Add ability to bulk set packages as private or public in both admin and repository package views. Includes new bulk action buttons, visibility grouping in repository view, and corresponding backend handlers for processing visibility changes. Admin can manage all packages while repository owners can manage their own packages.
This commit is contained in:
2026-02-07 15:02:16 -05:00
parent fb2d53ba7a
commit b2adcdf969
6 changed files with 307 additions and 31 deletions

View File

@@ -34,6 +34,9 @@
<div class="item" data-action="enable-global">{{svg "octicon-globe" 14}} {{ctx.Locale.Tr "admin.packages.bulk.enable_global"}}</div>
<div class="item" data-action="disable-global">{{svg "octicon-lock" 14}} {{ctx.Locale.Tr "admin.packages.bulk.disable_global"}}</div>
<div class="divider"></div>
<div class="item" data-action="make-private">{{svg "octicon-lock" 14}} {{ctx.Locale.Tr "admin.packages.bulk.make_private"}}</div>
<div class="item" data-action="make-public">{{svg "octicon-eye" 14}} {{ctx.Locale.Tr "admin.packages.bulk.make_public"}}</div>
<div class="divider"></div>
<div class="item" data-action="automatch">{{svg "octicon-link" 14}} {{ctx.Locale.Tr "admin.packages.bulk.automatch"}}</div>
</div>
</div>
@@ -58,6 +61,7 @@
</th>
<th>{{ctx.Locale.Tr "admin.packages.creator"}}</th>
<th>{{ctx.Locale.Tr "admin.packages.repository"}}</th>
<th>{{ctx.Locale.Tr "admin.packages.visibility"}}</th>
<th>{{ctx.Locale.Tr "admin.packages.global"}}</th>
<th>{{ctx.Locale.Tr "admin.packages.size"}}</th>
<th data-sortt-asc="created_asc" data-sortt-desc="created_desc">
@@ -91,6 +95,13 @@
</button>
{{end}}
</td>
<td>
{{if .Package.IsPrivate}}
<span class="ui tiny orange label">{{svg "octicon-lock" 12}} {{ctx.Locale.Tr "admin.packages.visibility.private"}}</span>
{{else}}
<span class="ui tiny label">{{svg "octicon-eye" 12}} {{ctx.Locale.Tr "admin.packages.visibility.public"}}</span>
{{end}}
</td>
<td>
{{if .Package.IsGlobal}}
<span class="ui tiny green label">{{svg "octicon-globe" 12}} {{ctx.Locale.Tr "admin.packages.global.yes"}}</span>
@@ -108,7 +119,7 @@
</td>
</tr>
{{else}}
<tr><td class="tw-text-center" colspan="12">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
<tr><td class="tw-text-center" colspan="13">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
{{end}}
</tbody>
</table>
@@ -177,6 +188,14 @@ document.addEventListener('DOMContentLoaded', function() {
url = '{{AppSubUrl}}/-/admin/packages/bulk-global';
formData.append('is_global', 'false');
break;
case 'make-private':
url = '{{AppSubUrl}}/-/admin/packages/bulk-private';
formData.append('is_private', 'true');
break;
case 'make-public':
url = '{{AppSubUrl}}/-/admin/packages/bulk-private';
formData.append('is_private', 'false');
break;
case 'automatch':
url = '{{AppSubUrl}}/-/admin/packages/bulk-automatch';
break;

View File

@@ -14,37 +14,125 @@
</div>
</form>
{{end}}
<div>
{{range .PackageDescriptors}}
<div class="flex-list">
<div class="flex-item">
<div class="flex-item-main">
<div class="flex-item-title">
<a href="{{.VersionWebLink}}">{{.Package.Name}}</a>
<span class="ui label">{{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}</span>
{{if .Package.IsPrivate}}
<span class="ui basic orange label">{{ctx.Locale.Tr "repo.visibility.private"}}</span>
{{end}}
</div>
<div class="flex-item-body">
{{$timeStr := DateUtils.TimeSince .Version.CreatedUnix}}
{{$hasRepositoryAccess := false}}
{{if .Repository}}
{{$hasRepositoryAccess = index $.RepositoryAccessMap .Repository.ID}}
{{end}}
{{if $hasRepositoryAccess}}
{{ctx.Locale.Tr "packages.published_by_in" $timeStr .Creator.HomeLink .Creator.GetDisplayName .Repository.Link .Repository.FullName}}
{{else}}
{{ctx.Locale.Tr "packages.published_by" $timeStr .Creator.HomeLink .Creator.GetDisplayName}}
{{end}}
</div>
</div>
<div class="flex-item-trailing">
<span class="text grey">{{DateUtils.TimeSince .Version.CreatedUnix}}</span>
</div>
{{if and .CanWritePackages (or .PublicPackages .PrivatePackages)}}
<div class="tw-flex tw-gap-2 tw-my-3">
<div class="ui small dropdown button" id="pkg-bulk-actions">
<span class="text">{{ctx.Locale.Tr "packages.bulk.actions"}}</span>
{{svg "octicon-triangle-down" 14}}
<div class="menu">
<div class="item" data-action="make-private">{{svg "octicon-lock" 14}} {{ctx.Locale.Tr "packages.bulk.make_private"}}</div>
<div class="item" data-action="make-public">{{svg "octicon-globe" 14}} {{ctx.Locale.Tr "packages.bulk.make_public"}}</div>
</div>
</div>
{{else}}
<span class="tw-text-text-light-2 tw-self-center" id="pkg-selected-count"></span>
</div>
{{end}}
<div>
{{/* Public Packages Section */}}
{{if .PublicPackages}}
<div class="tw-mb-6">
<h4 class="ui top attached header tw-flex tw-items-center tw-gap-2">
{{svg "octicon-globe" 16}}
{{ctx.Locale.Tr "packages.visibility.public"}}
<span class="ui small label">{{len .PublicPackages}}</span>
{{if .CanWritePackages}}
<div class="tw-ml-auto">
<input type="checkbox" class="select-section-packages" data-section="public" title="{{ctx.Locale.Tr "packages.bulk.select_all"}}">
</div>
{{end}}
</h4>
<div class="ui attached segment">
{{range .PublicPackages}}
<div class="flex-list">
<div class="flex-item">
{{if $.CanWritePackages}}
<div class="flex-item-leading">
<input type="checkbox" class="package-checkbox" data-package-id="{{.Package.ID}}" data-section="public">
</div>
{{end}}
<div class="flex-item-main">
<div class="flex-item-title">
<a href="{{.VersionWebLink}}">{{.Package.Name}}</a>
<span class="ui label">{{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}</span>
</div>
<div class="flex-item-body">
{{$timeStr := DateUtils.TimeSince .Version.CreatedUnix}}
{{$hasRepositoryAccess := false}}
{{if .Repository}}
{{$hasRepositoryAccess = index $.RepositoryAccessMap .Repository.ID}}
{{end}}
{{if $hasRepositoryAccess}}
{{ctx.Locale.Tr "packages.published_by_in" $timeStr .Creator.HomeLink .Creator.GetDisplayName .Repository.Link .Repository.FullName}}
{{else}}
{{ctx.Locale.Tr "packages.published_by" $timeStr .Creator.HomeLink .Creator.GetDisplayName}}
{{end}}
</div>
</div>
<div class="flex-item-trailing">
<span class="text grey">{{DateUtils.TimeSince .Version.CreatedUnix}}</span>
</div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{/* Private Packages Section */}}
{{if .PrivatePackages}}
<div class="tw-mb-6">
<h4 class="ui top attached header tw-flex tw-items-center tw-gap-2">
{{svg "octicon-lock" 16}}
{{ctx.Locale.Tr "packages.visibility.private"}}
<span class="ui small orange label">{{len .PrivatePackages}}</span>
{{if .CanWritePackages}}
<div class="tw-ml-auto">
<input type="checkbox" class="select-section-packages" data-section="private" title="{{ctx.Locale.Tr "packages.bulk.select_all"}}">
</div>
{{end}}
</h4>
<div class="ui attached segment">
{{range .PrivatePackages}}
<div class="flex-list">
<div class="flex-item">
{{if $.CanWritePackages}}
<div class="flex-item-leading">
<input type="checkbox" class="package-checkbox" data-package-id="{{.Package.ID}}" data-section="private">
</div>
{{end}}
<div class="flex-item-main">
<div class="flex-item-title">
<a href="{{.VersionWebLink}}">{{.Package.Name}}</a>
<span class="ui label">{{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}</span>
<span class="ui basic orange label">{{ctx.Locale.Tr "repo.visibility.private"}}</span>
</div>
<div class="flex-item-body">
{{$timeStr := DateUtils.TimeSince .Version.CreatedUnix}}
{{$hasRepositoryAccess := false}}
{{if .Repository}}
{{$hasRepositoryAccess = index $.RepositoryAccessMap .Repository.ID}}
{{end}}
{{if $hasRepositoryAccess}}
{{ctx.Locale.Tr "packages.published_by_in" $timeStr .Creator.HomeLink .Creator.GetDisplayName .Repository.Link .Repository.FullName}}
{{else}}
{{ctx.Locale.Tr "packages.published_by" $timeStr .Creator.HomeLink .Creator.GetDisplayName}}
{{end}}
</div>
</div>
<div class="flex-item-trailing">
<span class="text grey">{{DateUtils.TimeSince .Version.CreatedUnix}}</span>
</div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{/* Empty state - no packages at all */}}
{{if and (not .PublicPackages) (not .PrivatePackages)}}
{{if not .HasPackages}}
<div class="empty-placeholder">
{{svg "octicon-package" 48}}
@@ -61,3 +149,76 @@
{{end}}
{{template "base/paginate" .}}
</div>
{{if .CanWritePackages}}
<script>
document.addEventListener('DOMContentLoaded', function() {
const sectionCheckboxes = document.querySelectorAll('.select-section-packages');
const packageCheckboxes = document.querySelectorAll('.package-checkbox');
const selectedCountEl = document.getElementById('pkg-selected-count');
const bulkDropdown = document.getElementById('pkg-bulk-actions');
function updateSelectedCount() {
const selected = document.querySelectorAll('.package-checkbox:checked').length;
if (selected > 0) {
selectedCountEl.textContent = '{{ctx.Locale.Tr "packages.bulk.selected"}} ' + selected;
} else {
selectedCountEl.textContent = '';
}
}
// Section checkbox toggles all packages in that section
sectionCheckboxes.forEach(cb => {
cb.addEventListener('change', function() {
const section = this.dataset.section;
document.querySelectorAll(`.package-checkbox[data-section="${section}"]`).forEach(pkg => {
pkg.checked = this.checked;
});
updateSelectedCount();
});
});
packageCheckboxes.forEach(cb => {
cb.addEventListener('change', updateSelectedCount);
});
function getSelectedIds() {
return Array.from(document.querySelectorAll('.package-checkbox:checked'))
.map(cb => cb.dataset.packageId);
}
bulkDropdown?.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', function() {
const action = this.dataset.action;
const ids = getSelectedIds();
if (ids.length === 0) {
alert('{{ctx.Locale.Tr "packages.bulk.no_selection"}}');
return;
}
const formData = new FormData();
ids.forEach(id => formData.append('ids[]', id));
if (action === 'make-private') {
formData.append('is_private', 'true');
} else if (action === 'make-public') {
formData.append('is_private', 'false');
}
fetch(window.location.pathname + '/bulk-visibility', {
method: 'POST',
body: formData,
headers: {
'X-Csrf-Token': document.querySelector('meta[name=_csrf]')?.content || ''
}
}).then(response => response.json())
.then(data => {
if (data.ok) {
window.location.reload();
}
});
});
});
});
</script>
{{end}}