feat(branding): toggle login-page logo frame + size

This commit is contained in:
Luca
2026-05-11 11:02:33 +02:00
parent dde72a1b1b
commit 75e41eba03
14 changed files with 273 additions and 31 deletions
+22
View File
@@ -303,6 +303,16 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_mode,
hide_powered_by,
force_color_mode,
// Login-page-only branding (#354 follow-up). Both toggles apply
// exclusively to /admin/login and /customer/login — the gallery
// and admin chrome use their own logo_size / logo_max_height.
// - login_logo_frame_enabled: true (default) renders the tinted
// square behind the logo; false drops it.
// - login_logo_size: 'small' | 'medium' | 'large' | 'xlarge'
// matches the gallery logo_size token set but applies only to
// the two login screens.
login_logo_frame_enabled,
login_logo_size,
// Footer overhaul (#441 + #440). Socials are URL strings (empty
// hides the icon). Promo content is markdown (rendered via
// marked → DOMPurify on the frontend, no raw HTML accepted).
@@ -330,6 +340,13 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
? 'below_footer'
: 'above_footer';
// Normalize login_logo_size to the same token set as logo_size.
// Anything else falls back to 'medium' on the next render.
const allowedLoginLogoSizes = ['small', 'medium', 'large', 'xlarge'];
const normalizedLoginLogoSize = allowedLoginLogoSizes.includes(login_logo_size)
? login_logo_size
: undefined;
const brandingSettings = {
company_name,
company_tagline,
@@ -350,6 +367,11 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_mode,
hide_powered_by,
force_color_mode: normalizedForceColorMode,
// Login-only knobs (only persist when the request actually
// included the key, so a partial PUT from another tab doesn't
// accidentally clear them).
...(login_logo_frame_enabled !== undefined && { login_logo_frame_enabled }),
...(normalizedLoginLogoSize !== undefined && { login_logo_size: normalizedLoginLogoSize }),
// Footer overhaul (#441 + #440). String fields normalize empty/
// undefined → '' so the column is always a known type. Only persist
// when the request actually included the key (partial PUTs).
+9
View File
@@ -85,6 +85,15 @@ router.get('/', async (req, res) => {
: settingsObject.branding_force_color_mode === 'light'
? 'light'
: null,
// Login-page-only branding (#354 follow-up). Applies exclusively
// to /admin/login and /customer/login — the rest of the app keeps
// using branding_logo_size / branding_logo_max_height. Default
// true / 'medium' preserves the visual state shipped before the
// toggles existed.
branding_login_logo_frame_enabled: settingsObject.branding_login_logo_frame_enabled !== false,
branding_login_logo_size: ['small', 'medium', 'large', 'xlarge'].includes(settingsObject.branding_login_logo_size)
? settingsObject.branding_login_logo_size
: 'medium',
theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false,
+11
View File
@@ -1771,6 +1771,17 @@
"straight": "Gerade",
"angle": "Winkel",
"curve": "Kurve"
},
"loginLogo": {
"title": "Logo auf Login-Seiten",
"subtitle": "Wirkt nur auf /admin/login und /customer/login. Galerie und Admin-Chrome verwenden die Logo-Einstellungen oben.",
"frame": "Hellen Rahmen hinter dem Logo anzeigen",
"frameHelp": "Wenn aus, sitzt das Logo direkt auf dem Seitenhintergrund. Nützlich für Logos, die bereits einen eigenen Hintergrund haben.",
"size": "Logogröße auf den Login-Seiten",
"sizeSmall": "Klein",
"sizeMedium": "Mittel (Standard)",
"sizeLarge": "Groß",
"sizeXLarge": "Sehr groß"
}
},
"admin": {
+11
View File
@@ -1441,6 +1441,17 @@
"angle": "Angle",
"curve": "Curve",
"none": "None"
},
"loginLogo": {
"title": "Login pages logo",
"subtitle": "Controls only /admin/login and /customer/login. Gallery and admin chrome use the logo settings above.",
"frame": "Show tinted frame behind the logo",
"frameHelp": "When off, the logo sits directly on the page background. Useful for logos that already include their own backdrop.",
"size": "Logo size on login pages",
"sizeSmall": "Small",
"sizeMedium": "Medium (default)",
"sizeLarge": "Large",
"sizeXLarge": "Extra large"
}
},
"admin": {
+11
View File
@@ -1379,6 +1379,17 @@
"straight": "Droit",
"angle": "Angle",
"curve": "Courbe"
},
"loginLogo": {
"title": "Logo des pages de connexion",
"subtitle": "S'applique uniquement à /admin/login et /customer/login. La galerie et l'interface admin utilisent les paramètres de logo ci-dessus.",
"frame": "Afficher le cadre teinté derrière le logo",
"frameHelp": "Lorsque désactivé, le logo se pose directement sur l'arrière-plan de la page. Utile pour les logos qui ont déjà leur propre fond.",
"size": "Taille du logo sur les pages de connexion",
"sizeSmall": "Petite",
"sizeMedium": "Moyenne (par défaut)",
"sizeLarge": "Grande",
"sizeXLarge": "Très grande"
}
},
"admin": {
+11
View File
@@ -1441,6 +1441,17 @@
"straight": "Recht",
"angle": "Hoek",
"curve": "Boog"
},
"loginLogo": {
"title": "Logo op loginpagina",
"subtitle": "Geldt alleen voor /admin/login en /customer/login. Galerij en beheer gebruiken de logo-instellingen hierboven.",
"frame": "Getint kader achter het logo tonen",
"frameHelp": "Wanneer uitgeschakeld staat het logo direct op de pagina-achtergrond. Handig voor logo's met een eigen achtergrond.",
"size": "Logogrootte op loginpagina's",
"sizeSmall": "Klein",
"sizeMedium": "Middel (standaard)",
"sizeLarge": "Groot",
"sizeXLarge": "Extra groot"
}
},
"admin": {
+11
View File
@@ -1458,6 +1458,17 @@
"straight": "Reto",
"angle": "Ângulo",
"curve": "Curva"
},
"loginLogo": {
"title": "Logo nas páginas de login",
"subtitle": "Aplica-se apenas a /admin/login e /customer/login. A galeria e a interface admin usam as configurações de logo acima.",
"frame": "Mostrar moldura tonalizada atrás do logo",
"frameHelp": "Quando desativado, o logo fica diretamente sobre o fundo da página. Útil para logos que já incluem seu próprio fundo.",
"size": "Tamanho do logo nas páginas de login",
"sizeSmall": "Pequeno",
"sizeMedium": "Médio (padrão)",
"sizeLarge": "Grande",
"sizeXLarge": "Extra grande"
}
},
"admin": {
+11
View File
@@ -1475,6 +1475,17 @@
"straight": "Прямой",
"angle": "Угол",
"curve": "Кривая"
},
"loginLogo": {
"title": "Логотип на страницах входа",
"subtitle": "Применяется только к /admin/login и /customer/login. Галерея и админ-панель используют настройки логотипа выше.",
"frame": "Показывать тонированный фон за логотипом",
"frameHelp": "Если выключено, логотип будет на фоне страницы. Полезно для логотипов с собственным фоном.",
"size": "Размер логотипа на страницах входа",
"sizeSmall": "Маленький",
"sizeMedium": "Средний (по умолчанию)",
"sizeLarge": "Большой",
"sizeXLarge": "Очень большой"
}
},
"admin": {
+26 -11
View File
@@ -8,6 +8,7 @@ import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
import { api } from '../../config/api';
export const AdminLoginPage: React.FC = () => {
@@ -122,18 +123,32 @@ export const AdminLoginPage: React.FC = () => {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
{/* Logo/Header */}
{/* Logo/Header frame visibility and size are admin-controllable
via Branding "Login pages logo" settings. Both knobs apply
to /admin/login and /customer/login exclusively. */}
<div className="text-center mb-8">
<div
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: '#eee6d2' }}
>
<img
src={resolvedLogoUrl}
alt={companyName}
className="w-[180px] h-[130px] object-contain"
/>
</div>
{(() => {
const cls = resolveLoginLogoClasses(settingsData?.branding_login_logo_size);
const showFrame = settingsData?.branding_login_logo_frame_enabled !== false;
return showFrame ? (
<div
className={`${cls.frameOuter} mx-auto mb-6 rounded-2xl flex items-center justify-center`}
style={{ backgroundColor: '#eee6d2' }}
>
<img
src={resolvedLogoUrl}
alt={companyName}
className={`${cls.frameInner} object-contain`}
/>
</div>
) : (
<img
src={resolvedLogoUrl}
alt={companyName}
className={`${cls.bare} object-contain mx-auto mb-6`}
/>
);
})()}
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>{t('adminLogin.title')}</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>{t('adminLogin.subtitle')}</p>
</div>
+54
View File
@@ -34,6 +34,8 @@ export const BrandingPage: React.FC = () => {
logo_display_mode: 'logo_and_text',
hide_powered_by: false,
force_color_mode: null,
login_logo_frame_enabled: true,
login_logo_size: 'medium',
facebook_url: '',
instagram_url: '',
whatsapp_url: '',
@@ -670,6 +672,58 @@ export const BrandingPage: React.FC = () => {
</div>
</div>
{/* Login-page-only logo controls (#354 follow-up). These do
NOT affect gallery/admin headers those keep their own
logo_size knob above. */}
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('branding.loginLogo.title', 'Login pages logo')}
</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
{t('branding.loginLogo.subtitle', 'Controls only /admin/login and /customer/login. Gallery and admin chrome use the logo settings above.')}
</p>
<div className="space-y-4">
{/* Frame toggle */}
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={brandingSettings.login_logo_frame_enabled !== false}
onChange={(e) => handleBrandingChange('login_logo_frame_enabled', e.target.checked)}
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('branding.loginLogo.frame', 'Show tinted frame behind the logo')}
</span>
<p className="text-xs text-neutral-600 dark:text-neutral-400">
{t(
'branding.loginLogo.frameHelp',
'When off, the logo sits directly on the page background. Useful for logos that already include their own backdrop.',
)}
</p>
</div>
</label>
{/* Size selector */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.loginLogo.size', 'Logo size on login pages')}
</label>
<select
value={brandingSettings.login_logo_size || 'medium'}
onChange={(e) => handleBrandingChange('login_logo_size', e.target.value as 'small' | 'medium' | 'large' | 'xlarge')}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="small">{t('branding.loginLogo.sizeSmall', 'Small')}</option>
<option value="medium">{t('branding.loginLogo.sizeMedium', 'Medium (default)')}</option>
<option value="large">{t('branding.loginLogo.sizeLarge', 'Large')}</option>
<option value="xlarge">{t('branding.loginLogo.sizeXLarge', 'Extra large')}</option>
</select>
</div>
</div>
</div>
{/* White Label Settings */}
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.whiteLabel', 'White Label')}</h3>
@@ -14,6 +14,7 @@ import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
import { customerService } from '../../services/customer.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
export const CustomerLoginPage: React.FC = () => {
const { t } = useTranslation();
@@ -113,30 +114,34 @@ export const CustomerLoginPage: React.FC = () => {
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
>
<div className="w-full max-w-md">
{/* Logo / header matches AdminLoginPage's tinted square frame
so the brand presentation is identical across admin and
customer entry points. The frame itself is admin-controllable
via Branding "Show tinted frame behind login logo" same
toggle drives both login pages. */}
{/* Logo / header matches AdminLoginPage. The frame and size
are admin-controllable via Branding "Login pages logo"
settings; both toggles apply to /admin/login and
/customer/login exclusively (the rest of the app uses its
own logo_size). */}
<div className="text-center mb-8">
{settingsData?.branding_login_logo_frame_enabled !== false ? (
<div
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: '#eee6d2' }}
>
{(() => {
const cls = resolveLoginLogoClasses(settingsData?.branding_login_logo_size);
const showFrame = settingsData?.branding_login_logo_frame_enabled !== false;
return showFrame ? (
<div
className={`${cls.frameOuter} mx-auto mb-6 rounded-2xl flex items-center justify-center`}
style={{ backgroundColor: '#eee6d2' }}
>
<img
src={resolvedLogoUrl}
alt={companyName}
className={`${cls.frameInner} object-contain`}
/>
</div>
) : (
<img
src={resolvedLogoUrl}
alt={companyName}
className="w-[180px] h-[130px] object-contain"
className={`${cls.bare} object-contain mx-auto mb-6`}
/>
</div>
) : (
<img
src={resolvedLogoUrl}
alt={companyName}
className="h-24 w-auto object-contain mx-auto mb-6"
/>
)}
);
})()}
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>
{t('customer.login.title', 'Customer login')}
</h1>
@@ -25,6 +25,12 @@ export interface PublicSettings {
* AdminDarkModeContext + ThemeContext both honor this.
*/
branding_force_color_mode?: 'dark' | 'light' | null;
/**
* Login-page-only branding (#354 follow-up). Applies exclusively to
* /admin/login and /customer/login. Defaults: frame on, size 'medium'.
*/
branding_login_logo_frame_enabled?: boolean;
branding_login_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
// Footer overhaul (#441 + #440). Empty strings mean "hide".
branding_facebook_url?: string;
branding_instagram_url?: string;
+15 -1
View File
@@ -25,6 +25,13 @@ export interface BrandingSettings {
* `colorMode` override is ignored. `null` means no force (default behavior).
*/
force_color_mode?: 'dark' | 'light' | null;
/**
* Login-page-only branding (#354 follow-up). Applies exclusively to
* /admin/login and /customer/login. Frame default is true; size
* default is 'medium' (matches the visual state before the toggle).
*/
login_logo_frame_enabled?: boolean;
login_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
// Footer overhaul (#441 + #440). Empty strings hide each social
// icon individually; promo_markdown empty hides the slot for events
// in 'inherit' mode. Position controls global default placement.
@@ -309,7 +316,14 @@ export const settingsService = {
? 'dark'
: rawSettings.branding_force_color_mode === 'light'
? 'light'
: null
: null,
// Login-only knobs (#354). Default to frame ON, size 'medium' so
// installs that haven't toggled them keep the visual state shipped
// before the controls existed.
login_logo_frame_enabled: this._parseBoolean(rawSettings.branding_login_logo_frame_enabled, true),
login_logo_size: ['small', 'medium', 'large', 'xlarge'].includes(rawSettings.branding_login_logo_size)
? rawSettings.branding_login_logo_size
: 'medium'
};
},
+51
View File
@@ -0,0 +1,51 @@
/**
* Login-page logo sizing (#354 follow-up).
*
* Used ONLY on /admin/login and /customer/login. The rest of the app
* (gallery headers, admin chrome) keeps its own `branding_logo_size` /
* `branding_logo_max_height` knobs kept separate so admins can have a
* compact logo in the in-app header but a hero-sized one on the login
* splash.
*
* Returns Tailwind class strings for both modes:
* - `frame`: dimensions of the tinted square frame + the inner image.
* - `bare`: height/width of the standalone image when the frame is off.
*/
export type LoginLogoSize = 'small' | 'medium' | 'large' | 'xlarge';
interface LoginLogoClasses {
frameOuter: string; // tinted square dimensions
frameInner: string; // logo image inside the frame
bare: string; // logo image when frame is off
}
const SIZE_MAP: Record<LoginLogoSize, LoginLogoClasses> = {
small: {
frameOuter: 'w-[140px] h-[105px]',
frameInner: 'w-[120px] h-[90px]',
bare: 'h-16 w-auto',
},
medium: {
// Matches the visual state shipped before the size toggle existed.
frameOuter: 'w-[200px] h-[150px]',
frameInner: 'w-[180px] h-[130px]',
bare: 'h-24 w-auto',
},
large: {
frameOuter: 'w-[260px] h-[195px]',
frameInner: 'w-[240px] h-[175px]',
bare: 'h-32 w-auto',
},
xlarge: {
frameOuter: 'w-[320px] h-[240px]',
frameInner: 'w-[300px] h-[220px]',
bare: 'h-40 w-auto',
},
};
export function resolveLoginLogoClasses(size: LoginLogoSize | string | undefined | null): LoginLogoClasses {
if (size && size in SIZE_MAP) return SIZE_MAP[size as LoginLogoSize];
return SIZE_MAP.medium;
}
export const LOGIN_LOGO_SIZES: LoginLogoSize[] = ['small', 'medium', 'large', 'xlarge'];