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:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'] ?? ''));
|
||||||
|
|||||||
@@ -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): ?>
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ──
|
||||||
|
|||||||
Reference in New Issue
Block a user