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

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

View File

@@ -32,15 +32,15 @@
object-fit: contain;
object-position: center;
border: 1px solid var(--app-muted-border-color);
background-color: var(--app-preview-checker-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;
background-color: var(--app-preview-bg);
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 {
@@ -189,14 +189,7 @@
object-fit: contain;
object-position: center;
border: 1px solid var(--app-muted-border-color);
background-color: var(--app-preview-checker-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;
background-color: var(--app-preview-bg);
box-sizing: border-box;
}

View File

@@ -18,20 +18,15 @@
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 {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.tenant-logo-slot-label {
font-size: var(--text-sm);
color: var(--app-muted-color);
margin: 0;
}
.tenant-logo-actions {
display: flex;
justify-content: flex-end;

View File

@@ -68,27 +68,16 @@ export function initFileUpload(root) {
const filenameEl = root.querySelector('.app-file-upload-filename');
const filesizeEl = root.querySelector('.app-file-upload-filesize');
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 currentImage = root.querySelector('.app-file-upload-current-image');
const cleanupFns = [];
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 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) ──
const showFile = (file) => {
@@ -175,15 +164,19 @@ export function initFileUpload(root) {
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) {
const onReplace = (e) => {
e.preventDefault();
e.stopPropagation();
input.click();
};
replaceButton.addEventListener('click', onReplace);
cleanupFns.push(() => replaceButton.removeEventListener('click', onReplace));
replaceButton.addEventListener('click', openPicker);
cleanupFns.push(() => replaceButton.removeEventListener('click', openPicker));
}
if (currentImage) {
currentImage.addEventListener('click', openPicker);
cleanupFns.push(() => currentImage.removeEventListener('click', openPicker));
}
// ── Drag and drop ──