custom branding

This commit is contained in:
KingKDot
2026-07-08 21:47:57 -05:00
parent faac5b6029
commit b29d5b4291
13 changed files with 735 additions and 31 deletions
+32
View File
@@ -11,6 +11,9 @@ const loginTitle = document.getElementById("login-title");
const loginSubtitle = document.getElementById("login-subtitle");
const loginIcon = document.getElementById("login-icon");
const loginLogo = document.getElementById("login-logo");
const loginHeroImage = document.getElementById("login-hero-image");
const loginBrandFooter = document.getElementById("login-brand-footer");
const loginSupportLink = document.getElementById("login-support-link");
(() => {
const params = new URLSearchParams(window.location.search);
@@ -31,6 +34,7 @@ const loginLogo = document.getElementById("login-logo");
if (loginTitle && data.title) loginTitle.textContent = data.title;
if (loginSubtitle && data.subtitle) loginSubtitle.textContent = data.subtitle;
if (data.productName) document.title = `${data.productName} Login`;
if (data.accentColor) document.documentElement.style.setProperty("--brand-accent", data.accentColor);
if (loginIcon && data.iconClass) {
loginIcon.className = `${data.iconClass} login-crown`;
@@ -48,6 +52,34 @@ const loginLogo = document.getElementById("login-logo");
};
loginLogo.src = data.logoUrl;
}
if (loginHeroImage && data.heroImageUrl) {
loginHeroImage.alt = data.heroImageAlt || "";
loginHeroImage.onload = () => {
loginHeroImage.style.display = "";
document.body.classList.add("login-has-hero-image");
};
loginHeroImage.onerror = () => {
loginHeroImage.style.display = "none";
document.body.classList.remove("login-has-hero-image");
};
loginHeroImage.src = data.heroImageUrl;
}
if (loginBrandFooter && data.footerText) {
loginBrandFooter.textContent = data.footerText;
loginBrandFooter.style.display = "";
}
if (loginSupportLink && data.supportText && data.supportUrl) {
loginSupportLink.textContent = data.supportText;
loginSupportLink.href = data.supportUrl;
if (!data.supportUrl.startsWith("/")) {
loginSupportLink.target = "_blank";
loginSupportLink.rel = "noopener noreferrer";
}
loginSupportLink.style.display = "";
}
} catch {}
})();
+72 -4
View File
@@ -340,6 +340,27 @@ a {
overflow: hidden;
}
.login-hero-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.38;
filter: saturate(0.9) contrast(1.05);
pointer-events: none;
}
.login-body.login-has-hero-image::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(5, 7, 15, 0.88), rgba(5, 7, 15, 0.64)),
radial-gradient(75% 75% at 50% 25%, transparent, rgba(5, 7, 15, 0.62));
pointer-events: none;
}
.login-body::before {
content: "";
position: absolute;
@@ -984,6 +1005,7 @@ article.card[data-bookmarked="true"] {
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 1;
}
.particle {
@@ -1031,6 +1053,7 @@ article.card[data-bookmarked="true"] {
0 0 100px rgba(92, 145, 255, 0.1);
position: relative;
opacity: 0;
z-index: 2;
}
.login-card::before {
@@ -1056,8 +1079,8 @@ article.card[data-bookmarked="true"] {
.login-crown {
font-size: 48px;
color: #7a5bff;
filter: drop-shadow(0 4px 12px rgba(122, 91, 255, 0.5));
color: var(--brand-accent, #7a5bff);
filter: drop-shadow(0 4px 12px color-mix(in srgb, var(--brand-accent, #7a5bff) 50%, transparent));
}
.login-logo {
@@ -1065,7 +1088,7 @@ article.card[data-bookmarked="true"] {
height: 56px;
object-fit: contain;
border-radius: 12px;
filter: drop-shadow(0 4px 12px rgba(122, 91, 255, 0.35));
filter: drop-shadow(0 4px 12px color-mix(in srgb, var(--brand-accent, #7a5bff) 35%, transparent));
}
.login-brand {
@@ -1074,7 +1097,7 @@ article.card[data-bookmarked="true"] {
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 12px;
background: linear-gradient(135deg, #7a5bff, #5672ff);
background: linear-gradient(135deg, var(--brand-accent, #7a5bff), #7dd3fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -1097,6 +1120,24 @@ article.card[data-bookmarked="true"] {
opacity: 0;
}
.login-enterprise-footer,
.login-support-link {
display: block;
text-align: center;
font-size: 0.78rem;
color: #94a3b8;
margin-top: 12px;
}
.login-support-link {
color: #c4b5fd;
text-decoration: none;
}
.login-support-link:hover {
color: #e0e7ff;
}
.form-group {
margin-bottom: 20px;
opacity: 0;
@@ -4456,6 +4497,33 @@ body.sb-collapsed .sb-link .sb-text {
text-decoration: none;
}
.nav-brand-link {
min-width: 0;
}
.nav-brand-logo {
width: 24px;
height: 24px;
object-fit: contain;
border-radius: 6px;
flex: 0 0 auto;
filter: drop-shadow(0 2px 8px color-mix(in srgb, var(--brand-accent, #7a5bff) 28%, transparent));
}
.sb-logo .nav-brand-logo,
.sb-mobile-brand .nav-brand-logo {
width: 22px;
height: 22px;
}
#nav-brand-name,
#nav-mobile-brand-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Mobile backdrop ---- */
#sb-backdrop {
display: none;
+42
View File
@@ -22,6 +22,7 @@ import {
const host = document.getElementById("top-nav");
if (host) {
const refs = mountNav(host);
applyBranding();
initCommandPalette();
import("./cert-banner.js").then(({ showCertBannerIfNeeded }) => {
runWithoutPageTracking(() => showCertBannerIfNeeded(document.getElementById("sb-mobile-bar") || host));
@@ -246,3 +247,44 @@ if (host) {
setupSoftNavigation(host, { onPathChange: applyActivePath });
startPageTracking();
}
async function applyBranding() {
try {
const res = await fetch("/api/login/branding");
if (!res.ok) return;
const brand = await res.json();
if (brand.accentColor) {
document.documentElement.style.setProperty("--brand-accent", brand.accentColor);
}
const navName = brand.navName || brand.productName || "Overlord";
for (const el of document.querySelectorAll("#nav-brand-name, #nav-mobile-brand-name")) {
el.textContent = navName;
}
const iconClass = brand.iconClass || "fa-solid fa-crown";
for (const icon of document.querySelectorAll("#nav-brand-icon, #nav-mobile-brand-icon")) {
const sidebarIcon = Boolean(icon.closest(".sb-logo"));
icon.className = `${iconClass} header-crown${sidebarIcon ? " sb-icon" : ""}`;
}
const logoUrl = brand.navLogoUrl || brand.logoUrl || "";
if (!logoUrl) return;
for (const logo of document.querySelectorAll("#nav-brand-logo, #nav-mobile-brand-logo")) {
const pairedIcon = logo.id === "nav-mobile-brand-logo"
? document.getElementById("nav-mobile-brand-icon")
: document.getElementById("nav-brand-icon");
logo.alt = brand.navLogoAlt || brand.logoAlt || `${navName} logo`;
logo.onload = () => {
logo.style.display = "";
if (pairedIcon) pairedIcon.style.display = "none";
};
logo.onerror = () => {
logo.style.display = "none";
if (pairedIcon) pairedIcon.style.display = "";
};
logo.src = logoUrl;
}
} catch {}
}
+11 -7
View File
@@ -133,8 +133,10 @@ function mountTopbar(host) {
host.innerHTML = `
<div class="topbar-grid">
<div class="topbar-left">
<a href="/" class="flex items-center gap-2 font-semibold tracking-wide">
<i class="fa-solid fa-crown header-crown"></i> Overlord
<a href="/" class="nav-brand-link flex items-center gap-2 font-semibold tracking-wide">
<img id="nav-brand-logo" class="nav-brand-logo" alt="Overlord logo" style="display: none" />
<i id="nav-brand-icon" class="fa-solid fa-crown header-crown"></i>
<span id="nav-brand-name">Overlord</span>
</a>
<button id="topbar-nav-toggle" class="topbar-nav-toggle" aria-label="Open navigation menu" aria-expanded="false" type="button">
<i class="fa-solid fa-layer-group"></i>
@@ -281,9 +283,10 @@ function mountSidebar(host) {
host.innerHTML = `
<div class="sb-header">
<a href="/" class="sb-logo">
<i class="fa-solid fa-crown header-crown sb-icon"></i>
<span class="sb-text">Overlord</span>
<a href="/" class="sb-logo nav-brand-link">
<img id="nav-brand-logo" class="nav-brand-logo sb-icon" alt="Overlord logo" style="display: none" />
<i id="nav-brand-icon" class="fa-solid fa-crown header-crown sb-icon"></i>
<span id="nav-brand-name" class="sb-text">Overlord</span>
</a>
<button id="sb-collapse-btn" class="sb-collapse-btn" title="Toggle sidebar" aria-label="Toggle sidebar">
<i class="fa-solid fa-chevron-left"></i>
@@ -332,8 +335,9 @@ function mountSidebar(host) {
<i class="fa-solid fa-bars"></i>
</button>
<a href="/" class="sb-mobile-brand">
<i class="fa-solid fa-crown header-crown" style="font-size:0.85rem"></i>
<span>Overlord</span>
<img id="nav-mobile-brand-logo" class="nav-brand-logo" alt="Overlord logo" style="display: none" />
<i id="nav-mobile-brand-icon" class="fa-solid fa-crown header-crown" style="font-size:0.85rem"></i>
<span id="nav-mobile-brand-name">Overlord</span>
</a>
`;
host.insertAdjacentElement("afterend", mobileBar);
+162 -8
View File
@@ -108,6 +108,24 @@ const appearanceForm = document.getElementById("appearance-form");
const appearancePermissionNote = document.getElementById("appearance-permission-note");
const appearanceSaveBtn = document.getElementById("appearance-save-btn");
const appearanceCustomCssInput = document.getElementById("appearance-custom-css");
const brandProductNameInput = document.getElementById("brand-product-name");
const brandNavNameInput = document.getElementById("brand-nav-name");
const brandAccentColorInput = document.getElementById("brand-accent-color");
const brandIconClassInput = document.getElementById("brand-icon-class");
const brandNavLogoUrlInput = document.getElementById("brand-nav-logo-url");
const brandNavLogoFileInput = document.getElementById("brand-nav-logo-file");
const brandNavLogoAltInput = document.getElementById("brand-nav-logo-alt");
const brandLoginLogoUrlInput = document.getElementById("brand-login-logo-url");
const brandLoginLogoFileInput = document.getElementById("brand-login-logo-file");
const brandLoginLogoAltInput = document.getElementById("brand-login-logo-alt");
const brandLoginTitleInput = document.getElementById("brand-login-title");
const brandLoginSubtitleInput = document.getElementById("brand-login-subtitle");
const brandHeroImageUrlInput = document.getElementById("brand-hero-image-url");
const brandHeroImageFileInput = document.getElementById("brand-hero-image-file");
const brandHeroImageAltInput = document.getElementById("brand-hero-image-alt");
const brandFooterTextInput = document.getElementById("brand-footer-text");
const brandSupportTextInput = document.getElementById("brand-support-text");
const brandSupportUrlInput = document.getElementById("brand-support-url");
const exportImportSection = document.getElementById("export-import-section");
const exportSettingsBtn = document.getElementById("export-settings-btn");
@@ -1354,13 +1372,146 @@ function initSettingsSidebar() {
}, { passive: true });
}
const brandInputs = [
brandProductNameInput,
brandNavNameInput,
brandAccentColorInput,
brandIconClassInput,
brandNavLogoUrlInput,
brandNavLogoFileInput,
brandNavLogoAltInput,
brandLoginLogoUrlInput,
brandLoginLogoFileInput,
brandLoginLogoAltInput,
brandLoginTitleInput,
brandLoginSubtitleInput,
brandHeroImageUrlInput,
brandHeroImageFileInput,
brandHeroImageAltInput,
brandFooterTextInput,
brandSupportTextInput,
brandSupportUrlInput,
];
function setAppearanceFormDisabled(disabled) {
for (const input of [...brandInputs, appearanceCustomCssInput, appearanceSaveBtn]) {
if (input) input.disabled = disabled;
}
}
function applyBrandingForm(loginBranding = {}) {
if (brandProductNameInput) brandProductNameInput.value = loginBranding.productName || "Overlord";
if (brandNavNameInput) brandNavNameInput.value = loginBranding.navName || loginBranding.productName || "Overlord";
if (brandAccentColorInput) brandAccentColorInput.value = /^#[0-9a-fA-F]{6}$/.test(loginBranding.accentColor || "") ? loginBranding.accentColor : "#7a5bff";
if (brandIconClassInput) brandIconClassInput.value = loginBranding.iconClass || "fa-solid fa-crown";
if (brandNavLogoUrlInput) brandNavLogoUrlInput.value = loginBranding.navLogoUrl || "";
if (brandNavLogoAltInput) brandNavLogoAltInput.value = loginBranding.navLogoAlt || "";
if (brandLoginLogoUrlInput) brandLoginLogoUrlInput.value = loginBranding.logoUrl || "";
if (brandLoginLogoAltInput) brandLoginLogoAltInput.value = loginBranding.logoAlt || "";
if (brandLoginTitleInput) brandLoginTitleInput.value = loginBranding.title || "Welcome back";
if (brandLoginSubtitleInput) brandLoginSubtitleInput.value = loginBranding.subtitle || "Sign in to your control plane";
if (brandHeroImageUrlInput) brandHeroImageUrlInput.value = loginBranding.heroImageUrl || "";
if (brandHeroImageAltInput) brandHeroImageAltInput.value = loginBranding.heroImageAlt || "";
if (brandFooterTextInput) brandFooterTextInput.value = loginBranding.footerText || "";
if (brandSupportTextInput) brandSupportTextInput.value = loginBranding.supportText || "";
if (brandSupportUrlInput) brandSupportUrlInput.value = loginBranding.supportUrl || "";
}
function collectBrandingForm() {
return {
productName: String(brandProductNameInput?.value || "").trim(),
navName: String(brandNavNameInput?.value || "").trim(),
accentColor: String(brandAccentColorInput?.value || "").trim(),
iconClass: String(brandIconClassInput?.value || "").trim(),
navLogoUrl: String(brandNavLogoUrlInput?.value || "").trim(),
navLogoAlt: String(brandNavLogoAltInput?.value || "").trim(),
logoUrl: String(brandLoginLogoUrlInput?.value || "").trim(),
logoAlt: String(brandLoginLogoAltInput?.value || "").trim(),
title: String(brandLoginTitleInput?.value || "").trim(),
subtitle: String(brandLoginSubtitleInput?.value || "").trim(),
heroImageUrl: String(brandHeroImageUrlInput?.value || "").trim(),
heroImageAlt: String(brandHeroImageAltInput?.value || "").trim(),
footerText: String(brandFooterTextInput?.value || "").trim(),
supportText: String(brandSupportTextInput?.value || "").trim(),
supportUrl: String(brandSupportUrlInput?.value || "").trim(),
};
}
function setBrandingUploadName(fileInput) {
const label = document.getElementById(`${fileInput.id}-name`);
if (label) label.textContent = fileInput.files?.[0]?.name || "No file chosen";
}
async function uploadBrandingImage(fileInput, targetInput, kind, label) {
const file = fileInput?.files?.[0];
if (!file || !targetInput) return;
if (!requireUiPermission("system:appearance", "Appearance settings permission required.")) {
fileInput.value = "";
return;
}
if (file.size > 5 * 1024 * 1024) {
showMessage("Branding images must be 5 MB or smaller.", "error");
fileInput.value = "";
return;
}
const form = new FormData();
form.append("kind", kind);
form.append("file", file);
fileInput.disabled = true;
showMessage(`Uploading ${label}...`);
try {
const res = await fetch("/api/settings/appearance/image", {
method: "POST",
credentials: "include",
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || `Failed to upload ${label}.`, "error");
return;
}
targetInput.value = data.url || "";
showMessage(`${label} uploaded. Save Branding to apply it.`);
} catch {
showMessage(`Failed to upload ${label}.`, "error");
} finally {
fileInput.disabled = false;
fileInput.value = "";
setBrandingUploadName(fileInput);
}
}
function initBrandingUploads() {
if (brandNavLogoFileInput) {
brandNavLogoFileInput.addEventListener("change", () => {
setBrandingUploadName(brandNavLogoFileInput);
uploadBrandingImage(brandNavLogoFileInput, brandNavLogoUrlInput, "nav-logo", "Navigation logo");
});
}
if (brandLoginLogoFileInput) {
brandLoginLogoFileInput.addEventListener("change", () => {
setBrandingUploadName(brandLoginLogoFileInput);
uploadBrandingImage(brandLoginLogoFileInput, brandLoginLogoUrlInput, "login-logo", "Sign-in logo");
});
}
if (brandHeroImageFileInput) {
brandHeroImageFileInput.addEventListener("change", () => {
setBrandingUploadName(brandHeroImageFileInput);
uploadBrandingImage(brandHeroImageFileInput, brandHeroImageUrlInput, "hero-image", "Sign-in background image");
});
}
}
async function loadAppearanceSettings() {
if (!currentUser) return;
if (!userHas("system:appearance")) {
if (appearancePermissionNote) appearancePermissionNote.classList.remove("hidden");
if (appearanceCustomCssInput) appearanceCustomCssInput.disabled = true;
if (appearanceSaveBtn) appearanceSaveBtn.disabled = true;
setAppearanceFormDisabled(true);
return;
}
@@ -1369,14 +1520,15 @@ async function loadAppearanceSettings() {
try {
const res = await fetch("/api/settings/appearance", { credentials: "include" });
if (!res.ok) {
showMessage("Failed to load custom CSS settings.", "error");
showMessage("Failed to load branding settings.", "error");
return;
}
const data = await res.json().catch(() => ({}));
if (appearanceCustomCssInput) appearanceCustomCssInput.value = data.customCSS || "";
if (appearanceSaveBtn) appearanceSaveBtn.disabled = false;
applyBrandingForm(data.loginBranding || {});
setAppearanceFormDisabled(false);
} catch {
showMessage("Failed to load custom CSS settings.", "error");
showMessage("Failed to load branding settings.", "error");
}
}
@@ -1396,16 +1548,17 @@ async function saveAppearanceSettings(event) {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ customCSS }),
body: JSON.stringify({ customCSS, loginBranding: collectBrandingForm() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to save custom CSS.", "error");
showMessage(data.error || "Failed to save branding.", "error");
return;
}
showMessage("Custom CSS saved. Reload the page to apply the new styles.");
applyBrandingForm(data.loginBranding || collectBrandingForm());
showMessage("Branding saved. Reload any open page to apply it everywhere.");
}
const chatSettingsSection = document.getElementById("chat-settings-section");
@@ -2620,6 +2773,7 @@ async function init() {
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
if (oidcForm) oidcForm.addEventListener("submit", saveOidcSettings);
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
initBrandingUploads();
if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings);
if (inputArchiveUserForm) inputArchiveUserForm.addEventListener("submit", saveInputArchivePreference);
if (inputArchiveAdminForm) inputArchiveAdminForm.addEventListener("submit", saveInputArchiveAdminSettings);