feat(branding): implement background image library and selection functionality

Added a new API endpoint to list uploaded background images, enhancing the branding settings page with a background image library. Implemented UI components for selecting and displaying background images, including improved handling of background size and position. Updated tests to validate the new functionality and ensure proper integration with existing branding features.
This commit is contained in:
UNITRONIX
2026-06-20 20:11:39 +02:00
parent 5e4c8cd8f2
commit 7116c96c83
10 changed files with 304 additions and 19 deletions
+4
View File
@@ -474,6 +474,10 @@ func (s *Server) processIDChange(msg *pb.RegisterPk) *pb.RendezvousMessage {
log.Printf("[signal] Rejected invalid old peer ID in ID change: %q", oldID)
return registerPkResponse(pb.RegisterPkResponse_NOT_SUPPORT)
}
if !isValidPeerID(newID) {
log.Printf("[signal] Rejected invalid new peer ID in ID change: %q", newID)
return registerPkResponse(pb.RegisterPkResponse_NOT_SUPPORT)
}
oldPeer, err := s.db.GetPeer(oldID)
if err != nil {
+24
View File
@@ -256,6 +256,30 @@ func TestProcessIDChangeSoftDeletedTargetReturnsIDExists(t *testing.T) {
}
}
func TestProcessIDChangeRejectsInvalidNewID(t *testing.T) {
srv, database := newTestSignalServer(t, config.EnrollmentModeOpen)
if err := database.UpsertPeer(&db.Peer{ID: "OLD213", Status: "ONLINE"}); err != nil {
t.Fatal(err)
}
msg := newRegisterPk("bad/id")
msg.OldId = "OLD213"
resp := srv.processIDChange(msg)
if got := registerPkResult(resp); got != pb.RegisterPkResponse_NOT_SUPPORT {
t.Fatalf("ID change result = %v, want %v", got, pb.RegisterPkResponse_NOT_SUPPORT)
}
if peer, err := database.GetPeer("OLD213"); err != nil {
t.Fatal(err)
} else if peer == nil {
t.Fatal("OLD213 should remain active after rejected invalid target ID")
}
if entry := srv.peers.Get("bad/id"); entry != nil {
t.Fatalf("invalid target ID must not enter memory map: %+v", entry)
}
}
func TestProcessIDChangeRejectsSoftDeletedSource(t *testing.T) {
srv, database := newTestSignalServer(t, config.EnrollmentModeOpen)
+17 -2
View File
@@ -56,6 +56,14 @@
return /^#[0-9a-fA-F]{6}$/.test(v) ? v : fallback;
}
function applyBackgroundSizeMode(target, size) {
var mode = String(size || 'cover').trim();
if (['cover', 'contain', 'auto', 'center', 'repeat'].indexOf(mode) === -1) mode = 'cover';
target.style.backgroundSize = (mode === 'cover' || mode === 'contain') ? mode : 'auto';
target.style.backgroundRepeat = mode === 'repeat' ? 'repeat' : 'no-repeat';
target.style.backgroundPosition = mode === 'repeat' ? 'top left' : 'center';
}
function applyAppearancePayload(payload, baseUrl) {
var data = payload && (payload.data || payload.appearance || payload);
if (!data || typeof data !== 'object') return;
@@ -79,16 +87,23 @@
if (bg.type === 'image' && bg.imageUrl && baseUrl) {
var absolute = new URL(bg.imageUrl, baseUrl).toString();
document.body.style.backgroundImage = 'linear-gradient(rgba(0,0,0,.55), rgba(0,0,0,.55)), url("' + absolute.replace(/"/g, '%22') + '")';
document.body.style.backgroundSize = bg.size === 'contain' ? 'contain' : 'cover';
document.body.style.backgroundPosition = 'center';
document.body.style.backgroundColor = '';
applyBackgroundSizeMode(document.body, bg.size);
document.querySelectorAll('.card').forEach(function (card) {
card.style.backdropFilter = 'blur(18px)';
});
} else if (bg.type === 'gradient' && bg.gradient) {
document.body.style.backgroundImage = bg.gradient;
document.body.style.backgroundColor = '';
document.body.style.backgroundSize = 'cover';
document.body.style.backgroundRepeat = 'no-repeat';
document.body.style.backgroundPosition = 'center';
} else if (bg.type === 'color' && bg.color) {
document.body.style.backgroundImage = 'none';
document.body.style.backgroundColor = safeColor(bg.color, 'var(--bg)');
document.body.style.backgroundSize = '';
document.body.style.backgroundRepeat = '';
document.body.style.backgroundPosition = '';
}
}
+17 -2
View File
@@ -38,6 +38,14 @@
return /^#[0-9a-fA-F]{6}$/.test(v) ? v : fallback;
}
function applyBackgroundSizeMode(target, size) {
var mode = String(size || 'cover').trim();
if (['cover', 'contain', 'auto', 'center', 'repeat'].indexOf(mode) === -1) mode = 'cover';
target.style.backgroundSize = (mode === 'cover' || mode === 'contain') ? mode : 'auto';
target.style.backgroundRepeat = mode === 'repeat' ? 'repeat' : 'no-repeat';
target.style.backgroundPosition = mode === 'repeat' ? 'top left' : 'center';
}
function applyAppearancePayload(payload, baseUrl) {
var data = payload && (payload.data || payload.appearance || payload);
if (!data || typeof data !== 'object') return;
@@ -63,14 +71,21 @@
if (bg.type === 'image' && bg.imageUrl && baseUrl) {
var absolute = new URL(bg.imageUrl, baseUrl).toString();
document.body.style.backgroundImage = 'linear-gradient(rgba(0,0,0,.55), rgba(0,0,0,.55)), url("' + absolute.replace(/"/g, '%22') + '")';
document.body.style.backgroundSize = bg.size === 'contain' ? 'contain' : 'cover';
document.body.style.backgroundPosition = 'center';
document.body.style.backgroundColor = '';
applyBackgroundSizeMode(document.body, bg.size);
if (card) card.style.backdropFilter = 'blur(18px)';
} else if (bg.type === 'gradient' && bg.gradient) {
document.body.style.backgroundImage = bg.gradient;
document.body.style.backgroundColor = '';
document.body.style.backgroundSize = 'cover';
document.body.style.backgroundRepeat = 'no-repeat';
document.body.style.backgroundPosition = 'center';
} else if (bg.type === 'color' && bg.color) {
document.body.style.backgroundImage = 'none';
document.body.style.backgroundColor = safeColor(bg.color, 'var(--bg)');
document.body.style.backgroundSize = '';
document.body.style.backgroundRepeat = '';
document.body.style.backgroundPosition = '';
}
}
+90 -3
View File
@@ -1033,6 +1033,96 @@
background: var(--accent-red);
}
.background-picker {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.background-picker-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
}
.background-library {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-sm);
}
.background-library-item {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-xs);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: pointer;
text-align: left;
transition: border-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast);
}
.background-library-item:hover {
border-color: var(--accent-blue);
transform: translateY(-1px);
}
.background-library-item.active {
border-color: var(--accent-blue);
box-shadow: 0 0 0 2px var(--accent-blue-muted);
color: var(--text-primary);
}
.background-library-thumb {
display: block;
aspect-ratio: 16 / 9;
border-radius: var(--radius-md);
background-color: var(--bg-secondary);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.background-library-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-xs);
}
.background-library-empty {
grid-column: 1 / -1;
padding: var(--space-md);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-secondary);
background: var(--bg-tertiary);
}
.background-current-file {
max-width: none;
}
.background-advanced-url {
border-top: 1px solid var(--border-primary);
padding-top: var(--space-sm);
}
.background-advanced-url summary {
cursor: pointer;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.background-advanced-url .form-group {
margin-top: var(--space-sm);
}
/* Color grid */
.color-grid {
display: grid;
@@ -1216,9 +1306,6 @@
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
position: sticky;
top: 0;
z-index: 5;
box-shadow: var(--shadow-sm);
}
+15 -2
View File
@@ -227,6 +227,7 @@
el.style.backgroundImage = String(branding.bgGradient);
el.style.backgroundColor = '';
el.style.backgroundSize = 'cover';
el.style.backgroundRepeat = 'no-repeat';
el.style.backgroundPosition = 'center';
return true;
}
@@ -234,13 +235,20 @@
_wallpaperPath = branding.bgImageUrl;
el.style.backgroundImage = 'url("' + String(branding.bgImageUrl).replace(/"/g, '%22') + '")';
el.style.backgroundColor = '';
el.style.backgroundSize = (branding.bgSize === 'contain') ? 'contain' : 'cover';
el.style.backgroundPosition = 'center';
applyBackgroundSizeMode(el, branding.bgSize);
return true;
}
return false;
}
function applyBackgroundSizeMode(el, size) {
var mode = String(size || 'cover').trim();
if (['cover', 'contain', 'auto', 'center', 'repeat'].indexOf(mode) === -1) mode = 'cover';
el.style.backgroundSize = (mode === 'cover' || mode === 'contain') ? mode : 'auto';
el.style.backgroundRepeat = mode === 'repeat' ? 'repeat' : 'no-repeat';
el.style.backgroundPosition = mode === 'repeat' ? 'top left' : 'center';
}
/** Check if wallpaper files are available on server */
function probeWallpapers(cb) {
if (_wallpapersAvailable !== null) { cb(_wallpapersAvailable); return; }
@@ -258,6 +266,7 @@
el.style.backgroundImage = DEFAULT_GRADIENT;
el.style.backgroundColor = '#0d1117';
el.style.backgroundSize = 'cover';
el.style.backgroundRepeat = 'no-repeat';
el.style.backgroundPosition = 'center';
}
@@ -281,6 +290,7 @@
el.style.backgroundImage = 'none';
el.style.backgroundColor = color;
el.style.backgroundSize = '';
el.style.backgroundRepeat = '';
el.style.backgroundPosition = '';
localStorage.setItem(STORAGE_WALL, url);
localStorage.setItem(STORAGE_WALL_FIT, fit);
@@ -296,6 +306,7 @@
el.style.backgroundColor = '';
el.style.backgroundImage = 'url("' + url + '")';
el.style.backgroundSize = bgSize;
el.style.backgroundRepeat = 'no-repeat';
el.style.backgroundPosition = bgPos;
localStorage.setItem(STORAGE_WALL, url);
localStorage.setItem(STORAGE_WALL_FIT, fit);
@@ -308,6 +319,7 @@
newLayer.className = 'desktop-wallpaper-new';
newLayer.style.backgroundImage = 'url("' + url + '")';
newLayer.style.backgroundSize = bgSize;
newLayer.style.backgroundRepeat = 'no-repeat';
newLayer.style.backgroundPosition = bgPos;
el.appendChild(newLayer);
@@ -319,6 +331,7 @@
el.style.backgroundColor = '';
el.style.backgroundImage = 'url("' + url + '")';
el.style.backgroundSize = bgSize;
el.style.backgroundRepeat = 'no-repeat';
el.style.backgroundPosition = bgPos;
if (newLayer.parentElement) newLayer.remove();
}, 600);
+66
View File
@@ -1197,6 +1197,7 @@
if (rdclientOverlayVal) rdclientOverlayVal.textContent = data.rdclientBgOverlay || '0';
showBackgroundPanel('rdclient-bg', data.rdclientBgType || 'inherit');
syncExistingBackgroundUploadStatuses(data);
markSelectedBackgroundImage(data.bgImageUrl || '');
// Footer & custom CSS
setVal('footer-text', data.footerText || '');
@@ -1262,6 +1263,69 @@
input.click();
});
});
initBackgroundLibrary();
}
async function initBackgroundLibrary() {
const library = document.getElementById('bg-image-library');
if (!library) return;
library.addEventListener('click', (event) => {
const item = event.target.closest('.background-library-item');
if (!item) return;
selectBackgroundLibraryImage(item.dataset.url || '');
});
await loadBackgroundLibrary(document.getElementById('bg-image-url')?.value || '');
}
async function loadBackgroundLibrary(selectedUrl = '') {
const library = document.getElementById('bg-image-library');
if (!library) return;
library.innerHTML = `<div class="background-library-empty">${_('common.loading')}</div>`;
try {
const resp = await Utils.api('/api/settings/branding/backgrounds');
const items = Array.isArray(resp.data) ? resp.data : [];
renderBackgroundLibrary(items, selectedUrl || document.getElementById('bg-image-url')?.value || '');
} catch (err) {
library.innerHTML = `<div class="background-library-empty">${Utils.escapeHtml(err.message || _('errors.server_error'))}</div>`;
}
}
function renderBackgroundLibrary(items, selectedUrl = '') {
const library = document.getElementById('bg-image-library');
if (!library) return;
if (!items.length) {
library.innerHTML = `<div class="background-library-empty">${_('desktop.wp_unavailable')}</div>`;
return;
}
library.innerHTML = items.map(item => {
const url = String(item.url || '');
const name = String(item.name || url.split('/').pop() || '');
const isActive = url === selectedUrl;
return `
<button type="button" class="background-library-item${isActive ? ' active' : ''}" data-url="${Utils.escapeHtml(url)}" title="${Utils.escapeHtml(name)}">
<span class="background-library-thumb" style="background-image:url('${Utils.escapeHtml(url).replace(/'/g, '%27')}')"></span>
<span class="background-library-name">${Utils.escapeHtml(name)}</span>
</button>
`;
}).join('');
}
function selectBackgroundLibraryImage(url) {
if (!url) return;
const input = document.getElementById('bg-image-url');
if (input) input.value = url;
selectBackgroundImageType('bg-image-url');
setBackgroundUploadStatus(document.getElementById('bg-file-name'), url.split('/').pop() || url, url);
markSelectedBackgroundImage(url);
onBrandingFieldChange();
}
function markSelectedBackgroundImage(url) {
const library = document.getElementById('bg-image-library');
if (!library) return;
library.querySelectorAll('.background-library-item').forEach(item => {
item.classList.toggle('active', item.dataset.url === url);
});
}
/**
@@ -1359,6 +1423,8 @@
if (uploadedUrl) {
clearTimeout(_autosaveDebounce);
await loadBackgroundLibrary(uploadedUrl);
markSelectedBackgroundImage(uploadedUrl);
updateBackgroundUploadPanel(statusEl, {
state: 'success',
icon: 'check_circle',
+31
View File
@@ -313,6 +313,37 @@ const bgUpload = multer({
}
});
/**
* GET /api/settings/branding/backgrounds - List uploaded background images.
*/
router.get('/api/settings/branding/backgrounds', requireAuth, (req, res) => {
try {
const uploadsRoot = path.resolve(UPLOADS_DIR);
const managedPattern = /^bg-[0-9a-f]{16}\.(png|jpg|jpeg|gif|webp)$/i;
const files = fs.readdirSync(uploadsRoot)
.filter((name) => managedPattern.test(name))
.map((name) => {
const fullPath = path.resolve(uploadsRoot, name);
if (!fullPath.startsWith(uploadsRoot + path.sep)) return null;
const stat = fs.lstatSync(fullPath);
if (!stat.isFile() || stat.isSymbolicLink()) return null;
return {
name,
url: `/uploads/${name}`,
size: stat.size,
updatedAt: stat.mtime.toISOString()
};
})
.filter(Boolean)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
res.json({ success: true, data: files });
} catch (err) {
console.error('List branding backgrounds error:', err);
res.status(500).json({ success: false, error: req.t('errors.server_error') });
}
});
/**
* POST /api/settings/branding/upload-background - Upload a background image.
* Used by the console / login / agent-portal wallpaper pickers. Returns the
+23
View File
@@ -117,6 +117,29 @@ describe('Branding routes', () => {
});
describe('POST /api/settings/branding/upload-background', () => {
it('lists uploaded managed background images', async () => {
const uploadsDir = path.join(config.dataDir, 'uploads');
fs.mkdirSync(uploadsDir, { recursive: true });
const fileName = 'bg-0123456789abcdef.png';
const filePath = path.join(uploadsDir, fileName);
fs.writeFileSync(filePath, Buffer.from('managed-background'));
try {
const res = await request(app).get('/api/settings/branding/backgrounds');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toEqual(expect.arrayContaining([
expect.objectContaining({
name: fileName,
url: `/uploads/${fileName}`
})
]));
} finally {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
});
it('accepts a multipart background image upload', async () => {
const res = await request(app)
.post('/api/settings/branding/upload-background')
+17 -10
View File
@@ -811,16 +811,20 @@
</div>
<div class="logo-config-panel hidden" id="bg-image-panel">
<div class="form-group">
<label class="form-label">${_('branding.bg_image_upload')}</label>
<div class="logo-upload-area">
<div class="background-picker">
<div class="background-picker-head">
<div>
<label class="form-label">${_('desktop.wp_images')}</label>
<small class="form-hint">${_('branding.bg_image_hint')}</small>
</div>
<input type="file" id="bg-image-file" data-target="bg-image-url" accept="image/png,image/jpeg,image/webp,image/gif" class="logo-file-input branding-bg-file">
<label for="bg-image-file" class="logo-upload-btn">
<span class="material-icons">upload_file</span>
<span>${_('branding.bg_image_choose')}</span>
</label>
<span id="bg-file-name" class="logo-file-name"></span>
</div>
<div class="background-library" id="bg-image-library" aria-label="${_('desktop.wp_images')}"></div>
<span id="bg-file-name" class="logo-file-name background-current-file"></span>
<div class="branding-upload-status" id="bg-upload-status" hidden>
<div class="branding-upload-status-row">
<span class="material-icons branding-upload-status-icon">sync</span>
@@ -830,12 +834,15 @@
<div class="branding-upload-progress-fill"></div>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label" for="bg-image-url">${_('branding.bg_image_url')}</label>
<input type="text" id="bg-image-url" class="form-input"
placeholder="https://example.com/wallpaper.jpg">
<small class="form-hint">${_('branding.bg_image_hint')}</small>
<details class="background-advanced-url">
<summary>${_('settings.tab_advanced')}</summary>
<div class="form-group">
<label class="form-label" for="bg-image-url">${_('branding.bg_image_url')}</label>
<input type="text" id="bg-image-url" class="form-input"
placeholder="https://example.com/wallpaper.jpg">
<small class="form-hint">${_('branding.bg_image_hint')}</small>
</div>
</details>
</div>
<div class="form-group">
<label class="form-label" for="bg-size">${_('branding.bg_size')}</label>