feat(tenant): per-theme logos + file-upload + button UI polish

Replace the single tenant avatar with a pair of theme-scoped brand logos.
Render only the theme-matching <img> server-side and swap src on theme
toggle via a JS hook — no reload, no double request, no CSS tricks.

Tenant logos
- TenantLogoService (ImageUploadTrait) with theme whitelist and per-theme
  storage storage/tenants/{uuid}/logo/{light|dark}/, SIZES 128/256/512
- Public serving endpoint auth/tenant-logo-file so login can show the
  logo pre-auth; matching authenticated admin preview endpoint
- appTenantLogoUrl(?size, ?theme) with 4-step fallback cascade; PDF +
  mail always request 'light'
- Admin tenant edit: avatar block replaced by "Tenant logos" details
  block inside the Master-data tab, two side-by-side slots via Pico
  .grid with the core app-file-upload partial
- Policy rename ABILITY_ADMIN_TENANTS_AVATAR_VIEW -> LOGO_VIEW, action
  routes logo / logo-delete / logo-file with theme body/query param
- API endpoint path kept (backward compat), internals on new service
- CLI tenant:logo-migrate-avatars moves legacy avatar/ -> logo/light/
  idempotently (--dry-run, --yes, --cleanup)
- i18n "Tenant image" removed, 12 new keys synced across de/en

File upload component
- Full-width preview + filename/actions below (3D stack layout)
- Fixed 16:9 aspect ratio with 1rem inner padding for consistent
  preview size across any logo aspect
- Transparency checker pattern as background so black logos stay
  visible on dark mode and white logos on light mode
- form="" + deleteFormId support so the partial works with barrier
  forms inside another form

Buttons
- width:100% dropped from button[type="submit"]; scoped back via
  .login-main for the auth-flow primary CTA
- .outline base rule now tints background via color-mix of --app-color
  so secondary/primary/danger outlines all gain a subtle surface
- .outline.secondary restyled Stripe-style in both themes: solid white
  chip with soft shadow in light, solid elevated dark chip with white
  text in dark; neutral border replaces role-colored border
- .app-action-success/.app-action-danger outlines get color-mix bg +
  theme-aware outline-text tokens for stronger contrast
- Filled .primary/.app-action-success/.app-action-danger get raised
  box-shadow (inset highlight + drop) — opt-in via class so chrome
  buttons stay flat
- Dropped the legacy .secondary utility that was clobbering the
  custom-property cascade with a hardcoded muted color

Theme swap
- Logo img carries data-src-light + data-src-dark; theme-toggle JS
  swaps src when data-theme changes, keeping the topbar/login logo in
  sync without a page reload

Quality gates: PHPUnit (2045), PHPStan L5, CS-Fixer, docs link/drift,
codex skills sync — all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 20:25:53 +02:00
parent dbeac6b095
commit 6e3fc63c1d
43 changed files with 1009 additions and 282 deletions

View File

@@ -11,11 +11,13 @@
* 'name' => 'avatar', // required — input name attribute
* 'accept' => 'image/*', // required — accepted file types
* 'hint' => t('Allowed file types: SVG, PNG, JPG, WEBP'),// optional — hint text below dropzone label
* 'currentSrc' => 'admin/tenants/avatar-file?uuid=…&size=128', // optional — URL of existing server file
* 'currentSrc' => 'admin/tenants/logo-file?uuid=…&theme=light&size=128', // optional — URL of existing server file
* 'currentLabel' => t('Current image'), // optional — label for current file preview
* 'deleteAction' => 'admin/tenants/avatar-delete/…', // optional — formaction for delete button (requires currentSrc)
* 'deleteAction' => 'admin/tenants/logo-delete/…', // optional — formaction for delete button (requires currentSrc)
* 'deleteConfirm' => t('Delete this image?'), // optional — confirm message on delete
* 'required' => false, // optional — make input required
* 'formId' => 'tenant-logo-light-form', // optional — HTML5 form attribute to associate input with an external form
* 'deleteFormId' => 'tenant-logo-light-delete-form', // optional — HTML5 form attribute for the delete button (bypasses formaction)
* ];
* require templatePath('partials/app-file-upload.phtml');
*
@@ -35,8 +37,10 @@ $uploadCurrentLabel = trim((string) ($fileUpload['currentLabel'] ?? t('Current i
$uploadDeleteAction = trim((string) ($fileUpload['deleteAction'] ?? ''));
$uploadDeleteConfirm = trim((string) ($fileUpload['deleteConfirm'] ?? t('Delete this image?')));
$uploadRequired = (bool) ($fileUpload['required'] ?? false);
$uploadFormId = trim((string) ($fileUpload['formId'] ?? ''));
$uploadDeleteFormId = trim((string) ($fileUpload['deleteFormId'] ?? ''));
$hasCurrent = $uploadCurrentSrc !== '';
$hasDelete = $hasCurrent && $uploadDeleteAction !== '';
$hasDelete = $hasCurrent && ($uploadDeleteAction !== '' || $uploadDeleteFormId !== '');
?>
<div data-app-component="file-upload" class="app-file-upload"
@@ -50,7 +54,7 @@ $hasDelete = $hasCurrent && $uploadDeleteAction !== '';
<button type="button" class="app-file-upload-replace-button"><?php e(t('Replace')); ?></button>
<?php if ($hasDelete): ?>
<button type="submit" class="app-file-upload-delete-button"
formaction="<?php e($uploadDeleteAction); ?>" formmethod="post"
<?php if ($uploadDeleteFormId !== ''): ?>form="<?php e($uploadDeleteFormId); ?>"<?php else: ?>formaction="<?php e($uploadDeleteAction); ?>" formmethod="post"<?php endif; ?>
data-confirm-message="<?php e($uploadDeleteConfirm); ?>"><?php e(t('Delete')); ?></button>
<?php endif; ?>
</span>
@@ -60,6 +64,7 @@ $hasDelete = $hasCurrent && $uploadDeleteAction !== '';
<label class="app-file-upload-dropzone">
<input type="file" name="<?php e($uploadName); ?>"
<?php if ($uploadAccept !== ''): ?>accept="<?php e($uploadAccept); ?>"<?php endif; ?>
<?php if ($uploadFormId !== ''): ?>form="<?php e($uploadFormId); ?>"<?php endif; ?>
<?php if ($uploadRequired): ?>required<?php endif; ?>>
<span class="app-file-upload-dropzone-icon"><i class="bi bi-cloud-arrow-up"></i></span>
<span class="app-file-upload-dropzone-label"><?php e(t('Drop file here or click to select')); ?></span>

View File

@@ -262,10 +262,12 @@ $layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
$currentTenant = is_array($layoutNav['currentTenant'] ?? null) ? $layoutNav['currentTenant'] : null;
$availableTenants = is_array($layoutNav['availableTenants'] ?? null) ? $layoutNav['availableTenants'] : [];
$tenantQueryParam = trim((string) ($layoutNav['tenantQueryParam'] ?? ''));
$tenantAvatar = is_array($layoutNav['tenantAvatar'] ?? null) ? $layoutNav['tenantAvatar'] : [];
$tenantUuid = trim((string) ($tenantAvatar['uuid'] ?? ''));
$tenantName = trim((string) ($tenantAvatar['name'] ?? ''));
$hasTenantAvatar = !empty($tenantAvatar['hasAvatar']);
$tenantLogo = is_array($layoutNav['tenantLogo'] ?? null) ? $layoutNav['tenantLogo'] : [];
$tenantUuid = trim((string) ($tenantLogo['uuid'] ?? ''));
$tenantName = trim((string) ($tenantLogo['name'] ?? ''));
$hasTenantLogoLight = !empty($tenantLogo['hasLogoLight']);
$hasTenantLogoDark = !empty($tenantLogo['hasLogoDark']);
$hasTenantLogo = $hasTenantLogoLight || $hasTenantLogoDark;
$csrfKey = trim((string) ($layoutNav['csrfKey'] ?? \MintyPHP\Session::$csrfSessionKey));
$csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];

View File

@@ -13,10 +13,12 @@ $csrfToken = $_SESSION[$csrfKey] ?? '';
// Tenant branding data (from $layoutNav, same source as app-main-aside.phtml)
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
$tenantAvatar = is_array($layoutNav['tenantAvatar'] ?? null) ? $layoutNav['tenantAvatar'] : [];
$brandTenantUuid = trim((string) ($tenantAvatar['uuid'] ?? ''));
$brandTenantName = trim((string) ($tenantAvatar['name'] ?? ''));
$brandHasTenantAvatar = !empty($tenantAvatar['hasAvatar']);
$tenantLogo = is_array($layoutNav['tenantLogo'] ?? null) ? $layoutNav['tenantLogo'] : [];
$brandTenantName = trim((string) ($tenantLogo['name'] ?? ''));
$brandHasTenantLogo = !empty($tenantLogo['hasLogoLight']) || !empty($tenantLogo['hasLogoDark']);
$brandTenantLogoUrl = $brandHasTenantLogo ? appTenantLogoUrl(256, $theme) : '';
$brandTenantLogoLight = $brandHasTenantLogo ? appTenantLogoUrl(256, 'light') : '';
$brandTenantLogoDark = $brandHasTenantLogo ? appTenantLogoUrl(256, 'dark') : '';
// Tenant switcher data
$currentTenant = $_SESSION['current_tenant'] ?? null;
@@ -45,8 +47,12 @@ $moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $modu
<i class="bi bi-list" aria-hidden="true"></i>
</button>
<a href="<?php e(lurl('')); ?>" class="app-topbar-brand">
<?php if ($brandHasTenantAvatar): ?>
<img src="auth/tenant-avatar-file?uuid=<?php e($brandTenantUuid); ?>&amp;size=256" alt="<?php e($brandTenantName); ?>">
<?php if ($brandTenantLogoUrl !== ''): ?>
<img class="app-tenant-logo" src="<?php e($brandTenantLogoUrl); ?>"
data-theme-src
data-src-light="<?php e($brandTenantLogoLight); ?>"
data-src-dark="<?php e($brandTenantLogoDark); ?>"
alt="<?php e($brandTenantName); ?>">
<?php elseif ($brandTenantName !== ''): ?>
<span class="app-topbar-brand-name"><?php e($brandTenantName); ?></span>
<?php else: ?>

View File

@@ -1,17 +1,29 @@
<?php
$authLogoHref = isset($authLogoHref) && is_string($authLogoHref) ? trim($authLogoHref) : '';
$authLogoUrl = appAuthLogoUrl();
if ($authLogoUrl === '') {
return;
}
$authLogoHref = isset($authLogoHref) && is_string($authLogoHref) ? trim($authLogoHref) : '';
// Offer both theme variants via data-* so the client can swap without reload.
$authLogoLight = function_exists('appTenantLogoUrl') ? appTenantLogoUrl(null, 'light') : $authLogoUrl;
$authLogoDark = function_exists('appTenantLogoUrl') ? appTenantLogoUrl(null, 'dark') : $authLogoUrl;
?>
<div class="login-logo">
<?php if ($authLogoHref !== ''): ?>
<a href="<?php e($authLogoHref); ?>" class="login-logo-link">
<img src="<?php e($authLogoUrl); ?>" alt="<?php e(appTitle()); ?>" class="login-logo-img">
<img src="<?php e($authLogoUrl); ?>"
data-theme-src
data-src-light="<?php e($authLogoLight); ?>"
data-src-dark="<?php e($authLogoDark); ?>"
alt="<?php e(appTitle()); ?>" class="login-logo-img">
</a>
<?php else: ?>
<img src="<?php e($authLogoUrl); ?>" alt="<?php e(appTitle()); ?>" class="login-logo-img">
<img src="<?php e($authLogoUrl); ?>"
data-theme-src
data-src-light="<?php e($authLogoLight); ?>"
data-src-dark="<?php e($authLogoDark); ?>"
alt="<?php e(appTitle()); ?>" class="login-logo-img">
<?php endif; ?>
</div>

View File

@@ -0,0 +1,48 @@
<?php
/**
* Single-theme tenant-logo slot. Uses the standard app-file-upload.phtml
* partial for the drop-zone UI (upload + current preview + replace + delete),
* wired to a pair of barrier forms via the HTML5 form attribute.
*
* Required view vars:
* $tenantUuid, $logoTheme ('light'|'dark'), $hasLogo (bool),
* $fieldLabel, $uploadFormId, $deleteFormId, $canUpdate (bool)
*/
$tenantUuid = (string) ($tenantUuid ?? '');
$logoTheme = (string) ($logoTheme ?? 'light');
$hasLogo = (bool) ($hasLogo ?? false);
$fieldLabel = (string) ($fieldLabel ?? '');
$uploadFormId = (string) ($uploadFormId ?? '');
$deleteFormId = (string) ($deleteFormId ?? '');
$canUpdate = (bool) ($canUpdate ?? false);
$previewUrl = $hasLogo
? 'admin/tenants/logo-file?uuid=' . rawurlencode($tenantUuid) . '&theme=' . rawurlencode($logoTheme) . '&size=256'
: '';
?>
<div class="tenant-logo-slot">
<span class="tenant-logo-slot-label"><?php e($fieldLabel); ?></span>
<?php if ($canUpdate && $uploadFormId !== ''): ?>
<?php
$fileUpload = [
'name' => 'logo',
'accept' => 'image/svg+xml,image/png,image/jpeg,image/webp',
'hint' => t('Allowed file types: SVG, PNG, JPG, WEBP'),
'currentSrc' => $previewUrl,
'currentLabel' => $fieldLabel,
'formId' => $uploadFormId,
'deleteFormId' => $hasLogo && $deleteFormId !== '' ? $deleteFormId : '',
'deleteConfirm' => t('Delete this logo?'),
];
require templatePath('partials/app-file-upload.phtml');
?>
<div class="tenant-logo-actions">
<button type="submit" form="<?php e($uploadFormId); ?>" class="secondary outline small">
<?php e(t('Save')); ?>
</button>
</div>
<?php elseif ($hasLogo): ?>
<img class="tenant-logo-readonly-preview" src="<?php e($previewUrl); ?>" alt="<?php e($fieldLabel); ?>">
<?php endif; ?>
</div>