refactor(file-upload): SSR-first state, click-to-replace, cleaner preview

- Server renders initial file-upload state (src, alt, label text,
  has-current class) so there is no flash of broken-image icon while
  JS boots. JS no longer duplicates that work — it only reacts to
  user input (select, clear, drag-and-drop) and uses data-current-src
  solely to restore the server file after a blob preview.
- Clicking the current preview image now opens the file dialog, mirrors
  the Replace button, with hover affordance (cursor + primary border).
- Drop the transparency checker pattern in the preview — the admin UI
  does not need Figma-style transparency semantics. Preview is now a
  flat theme-aware surface (--app-preview-bg) that keeps white or
  black logos visible against a neutral gray.
- Move tenant favicon out of the aside into the Master-data tab as its
  own details block next to the Tenant-logos section. Uses the same
  barrier-form pattern (HTML5 form attribute + app-file-upload.phtml)
  for a consistent instant-upload UX.
- Tenant-logo slot label is a native <label> element — picks up the
  global form-label typography and spacing, no custom muted-color
  class needed.

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

View File

@@ -336,6 +336,9 @@
"Remove": "Entfernen", "Remove": "Entfernen",
"Allowed file types: SVG, PNG, JPG, WEBP": "Erlaubte Dateitypen: SVG, PNG, JPG, WEBP", "Allowed file types: SVG, PNG, JPG, WEBP": "Erlaubte Dateitypen: SVG, PNG, JPG, WEBP",
"Favicon": "Favicon", "Favicon": "Favicon",
"Tenant favicon": "Mandanten-Favicon",
"Square PNG. Icons are center-cropped.": "Quadratisches PNG. Symbole werden mittig zugeschnitten.",
"Delete this favicon?": "Dieses Favicon löschen?",
"Upload favicon": "Favicon hochladen", "Upload favicon": "Favicon hochladen",
"Favicon updated": "Favicon aktualisiert", "Favicon updated": "Favicon aktualisiert",
"Favicon removed": "Favicon entfernt", "Favicon removed": "Favicon entfernt",

View File

@@ -336,6 +336,9 @@
"Remove": "Remove", "Remove": "Remove",
"Allowed file types: SVG, PNG, JPG, WEBP": "Allowed file types: SVG, PNG, JPG, WEBP", "Allowed file types: SVG, PNG, JPG, WEBP": "Allowed file types: SVG, PNG, JPG, WEBP",
"Favicon": "Favicon", "Favicon": "Favicon",
"Tenant favicon": "Tenant favicon",
"Square PNG. Icons are center-cropped.": "Square PNG. Icons are center-cropped.",
"Delete this favicon?": "Delete this favicon?",
"Upload favicon": "Upload favicon", "Upload favicon": "Upload favicon",
"Favicon updated": "Favicon updated", "Favicon updated": "Favicon updated",
"Favicon removed": "Favicon removed", "Favicon removed": "Favicon removed",

View File

@@ -235,6 +235,35 @@ $openOverrideCard = $detailsOpenAll;
</div> </div>
</details> </details>
<hr> <hr>
<details name="tenant-master-favicon" <?php e($detailsOpenAll ? 'open' : ''); ?>>
<summary><?php e(t('Tenant favicon')); ?></summary>
<hr>
<?php
$tenantFaviconUuid = (string) ($values['uuid'] ?? '');
$faviconPresent = $hasFavicon ?? false;
$fileUpload = [
'name' => 'favicon',
'accept' => 'image/png',
'hint' => t('Square PNG. Icons are center-cropped.'),
'currentSrc' => $faviconPresent && $tenantFaviconUuid !== ''
? asset('favicon/tenants/' . $tenantFaviconUuid . '/favicon/favicon-32x32.png')
: '',
'currentLabel' => t('Favicon'),
'formId' => 'tenant-favicon-form',
'deleteFormId' => $faviconPresent ? 'tenant-favicon-delete-form' : '',
'deleteConfirm' => t('Delete this favicon?'),
];
require templatePath('partials/app-file-upload.phtml');
?>
<?php if ($canUpdateTenant): ?>
<div class="tenant-logo-actions">
<button type="submit" form="tenant-favicon-form" class="secondary outline small">
<?php e(t('Save')); ?>
</button>
</div>
<?php endif; ?>
</details>
<hr>
<details name="tenant-master-contact" <?php e($detailsOpenAll ? 'open' : ''); ?>> <details name="tenant-master-contact" <?php e($detailsOpenAll ? 'open' : ''); ?>>
<summary><?php e(t('Contact')); ?></summary> <summary><?php e(t('Contact')); ?></summary>
<hr> <hr>

View File

@@ -77,6 +77,14 @@ $hasFavicon = $tenantUuid !== '' && $tenantFaviconService->hasFavicon($tenantUui
<input type="hidden" name="theme" value="dark"> <input type="hidden" name="theme" value="dark">
<?php Session::getCsrfInput(); ?> <?php Session::getCsrfInput(); ?>
</form> </form>
<form id="tenant-favicon-form" method="post"
action="admin/tenants/favicon/<?php e($tenantUuid); ?>" enctype="multipart/form-data" hidden>
<?php Session::getCsrfInput(); ?>
</form>
<form id="tenant-favicon-delete-form" method="post"
action="admin/tenants/favicon-delete/<?php e($tenantUuid); ?>" hidden>
<?php Session::getCsrfInput(); ?>
</form>
<?php endif; ?> <?php endif; ?>
<?php <?php
@@ -112,35 +120,6 @@ $hasFavicon = $tenantUuid !== '' && $tenantFaviconService->hasFavicon($tenantUui
<p><?php e(t('Tenant')); ?></p> <p><?php e(t('Tenant')); ?></p>
</hgroup> </hgroup>
<hr> <hr>
<?php if ($canUpdateTenant): ?>
<details name="tenant-favicon">
<summary>
<?php e(t('Upload favicon')); ?>
</summary>
<hr>
<small><?php e(t('Square images are recommended (icons are center-cropped).')); ?></small>
<hr>
<form class="user-avatar-form" method="post" action="admin/tenants/favicon/<?php e($tenantUuid); ?>"
enctype="multipart/form-data">
<?php
$fileUpload = [
'name' => 'favicon',
'accept' => 'image/png',
'hint' => t('Allowed file types: PNG'),
'currentSrc' => $hasFavicon ? asset('favicon/tenants/' . $tenantUuid . '/favicon/favicon-32x32.png') : '',
'currentLabel' => t('Favicon'),
'deleteAction' => $hasFavicon ? 'admin/tenants/favicon-delete/' . $tenantUuid : '',
];
require templatePath('partials/app-file-upload.phtml');
?>
<button type="submit" class="app-action-success">
<?php e(t('Save')); ?>
</button>
<?php Session::getCsrfInput(); ?>
</form>
</details>
<?php endif; ?>
<hr>
<?php <?php
$phone = trim((string) ($values['phone'] ?? '')); $phone = trim((string) ($values['phone'] ?? ''));
$fax = trim((string) ($values['fax'] ?? '')); $fax = trim((string) ($values['fax'] ?? ''));

View File

@@ -43,13 +43,13 @@ $hasCurrent = $uploadCurrentSrc !== '';
$hasDelete = $hasCurrent && ($uploadDeleteAction !== '' || $uploadDeleteFormId !== ''); $hasDelete = $hasCurrent && ($uploadDeleteAction !== '' || $uploadDeleteFormId !== '');
?> ?>
<div data-app-component="file-upload" class="app-file-upload" <div data-app-component="file-upload" class="app-file-upload<?php e($hasCurrent ? ' has-current' : ''); ?>"
<?php if ($hasCurrent): ?>data-current-src="<?php e($uploadCurrentSrc); ?>" data-current-label="<?php e($uploadCurrentLabel); ?>"<?php endif; ?>> <?php if ($hasCurrent): ?>data-current-src="<?php e($uploadCurrentSrc); ?>"<?php endif; ?>>
<?php if ($hasCurrent): ?> <?php if ($hasCurrent): ?>
<div class="app-file-upload-current"> <div class="app-file-upload-current">
<img class="app-file-upload-current-image" src="" alt=""> <img class="app-file-upload-current-image" src="<?php e($uploadCurrentSrc); ?>" alt="<?php e($uploadCurrentLabel); ?>" loading="lazy">
<div class="app-file-upload-current-meta"> <div class="app-file-upload-current-meta">
<span class="app-file-upload-current-label"></span> <span class="app-file-upload-current-label"><?php e($uploadCurrentLabel); ?></span>
<span class="app-file-upload-current-actions"> <span class="app-file-upload-current-actions">
<button type="button" class="app-file-upload-replace-button"><?php e(t('Replace')); ?></button> <button type="button" class="app-file-upload-replace-button"><?php e(t('Replace')); ?></button>
<?php if ($hasDelete): ?> <?php if ($hasDelete): ?>

View File

@@ -22,7 +22,7 @@ $previewUrl = $hasLogo
: ''; : '';
?> ?>
<div class="tenant-logo-slot"> <div class="tenant-logo-slot">
<span class="tenant-logo-slot-label"><?php e($fieldLabel); ?></span> <label><?php e($fieldLabel); ?></label>
<?php if ($canUpdate && $uploadFormId !== ''): ?> <?php if ($canUpdate && $uploadFormId !== ''): ?>
<?php <?php
$fileUpload = [ $fileUpload = [

View File

@@ -120,8 +120,7 @@
--app-text-selection-color: rgba(2, 154, 232, 0.25); --app-text-selection-color: rgba(2, 154, 232, 0.25);
--app-muted-color: #646b79; --app-muted-color: #646b79;
--app-muted-border-color: rgb(231, 234, 239.5); --app-muted-border-color: rgb(231, 234, 239.5);
--app-preview-checker-bg: #ffffff; --app-preview-bg: #e8ebef;
--app-preview-checker-fg: #eef0f3;
--app-button-filled-shadow: --app-button-filled-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.2),
0 1px 2px rgba(0, 0, 0, 0.12); 0 1px 2px rgba(0, 0, 0, 0.12);
@@ -632,8 +631,7 @@
--app-text-selection-color: rgba(1, 170, 255, 0.1875); --app-text-selection-color: rgba(1, 170, 255, 0.1875);
--app-muted-color: #6e6e6c; --app-muted-color: #6e6e6c;
--app-muted-border-color: #2f2f2d; --app-muted-border-color: #2f2f2d;
--app-preview-checker-bg: #2a2a28; --app-preview-bg: #262a2f;
--app-preview-checker-fg: #363634;
--app-button-neutral-bg: #30343b; --app-button-neutral-bg: #30343b;
--app-button-neutral-bg-hover: #3b4048; --app-button-neutral-bg-hover: #3b4048;
--app-button-neutral-border: #464b54; --app-button-neutral-border: #464b54;

View File

@@ -32,15 +32,15 @@
object-fit: contain; object-fit: contain;
object-position: center; object-position: center;
border: 1px solid var(--app-muted-border-color); border: 1px solid var(--app-muted-border-color);
background-color: var(--app-preview-checker-bg); background-color: var(--app-preview-bg);
background-image:
linear-gradient(45deg, var(--app-preview-checker-fg) 25%, transparent 25%),
linear-gradient(-45deg, var(--app-preview-checker-fg) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--app-preview-checker-fg) 75%),
linear-gradient(-45deg, transparent 75%, var(--app-preview-checker-fg) 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
box-sizing: border-box; box-sizing: border-box;
cursor: pointer;
transition: border-color 0.15s ease, opacity 0.15s ease;
}
.app-file-upload-current-image:hover {
border-color: var(--app-primary);
opacity: 0.92;
} }
.app-file-upload-current-meta { .app-file-upload-current-meta {
@@ -189,14 +189,7 @@
object-fit: contain; object-fit: contain;
object-position: center; object-position: center;
border: 1px solid var(--app-muted-border-color); border: 1px solid var(--app-muted-border-color);
background-color: var(--app-preview-checker-bg); background-color: var(--app-preview-bg);
background-image:
linear-gradient(45deg, var(--app-preview-checker-fg) 25%, transparent 25%),
linear-gradient(-45deg, var(--app-preview-checker-fg) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--app-preview-checker-fg) 75%),
linear-gradient(-45deg, transparent 75%, var(--app-preview-checker-fg) 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
box-sizing: border-box; box-sizing: border-box;
} }

View File

@@ -18,20 +18,15 @@
max-height: calc(var(--app-topbar-height) - 1rem); max-height: calc(var(--app-topbar-height) - 1rem);
} }
/* Tenant-edit "Tenant logos" slots — two Pico .grid columns. */ /* Tenant-edit "Tenant logos" slots — two Pico .grid columns.
The slot's <label> uses the standard form-label rule (block,
margin-bottom, --app-color, text-sm) so it matches other inputs. */
.tenant-logo-slot { .tenant-logo-slot {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem;
min-width: 0; min-width: 0;
} }
.tenant-logo-slot-label {
font-size: var(--text-sm);
color: var(--app-muted-color);
margin: 0;
}
.tenant-logo-actions { .tenant-logo-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -68,27 +68,16 @@ export function initFileUpload(root) {
const filenameEl = root.querySelector('.app-file-upload-filename'); const filenameEl = root.querySelector('.app-file-upload-filename');
const filesizeEl = root.querySelector('.app-file-upload-filesize'); const filesizeEl = root.querySelector('.app-file-upload-filesize');
const clearButton = root.querySelector('.app-file-upload-clear'); const clearButton = root.querySelector('.app-file-upload-clear');
const currentImage = root.querySelector('.app-file-upload-current-image');
const currentLabel = root.querySelector('.app-file-upload-current-label');
const replaceButton = root.querySelector('.app-file-upload-replace-button'); const replaceButton = root.querySelector('.app-file-upload-replace-button');
const currentImage = root.querySelector('.app-file-upload-current-image');
const cleanupFns = []; const cleanupFns = [];
let dragCounter = 0; let dragCounter = 0;
// ── Initialize current file state from data attributes ── // Server rendered the initial state (img src + label + has-current class).
// `data-current-src` is kept so clearFile() can restore the server file
// after a user previewed a different selection.
const currentSrc = (root.dataset.currentSrc || '').trim(); const currentSrc = (root.dataset.currentSrc || '').trim();
const currentLabelText = (root.dataset.currentLabel || '').trim();
if (currentSrc) {
root.classList.add('has-current');
if (currentImage) {
currentImage.src = currentSrc;
currentImage.alt = currentLabelText;
}
if (currentLabel) {
currentLabel.textContent = currentLabelText;
}
}
// ── Show pending file (new selection) ── // ── Show pending file (new selection) ──
const showFile = (file) => { const showFile = (file) => {
@@ -175,15 +164,19 @@ export function initFileUpload(root) {
cleanupFns.push(() => clearButton.removeEventListener('click', onClear)); cleanupFns.push(() => clearButton.removeEventListener('click', onClear));
} }
// ── Replace button → open file dialog ── // ── Replace button + click on current preview → open file dialog ──
const openPicker = (e) => {
e.preventDefault();
e.stopPropagation();
input.click();
};
if (replaceButton) { if (replaceButton) {
const onReplace = (e) => { replaceButton.addEventListener('click', openPicker);
e.preventDefault(); cleanupFns.push(() => replaceButton.removeEventListener('click', openPicker));
e.stopPropagation(); }
input.click(); if (currentImage) {
}; currentImage.addEventListener('click', openPicker);
replaceButton.addEventListener('click', onReplace); cleanupFns.push(() => currentImage.removeEventListener('click', openPicker));
cleanupFns.push(() => replaceButton.removeEventListener('click', onReplace));
} }
// ── Drag and drop ── // ── Drag and drop ──