feat: add reusable app-file-upload component with drag-and-drop and preview

Replace bare <input type="file"> across all upload locations with a
card-based dropzone component via shared partial. Three visual states:
current server file (thumbnail + Replace/Delete), empty dropzone, and
pending file preview (local FileReader thumbnail + metadata). Delete
actions use data-confirm-message for confirmation dialog.

Centralized as templates/partials/app-file-upload.phtml to prevent
markup drift — documented as deliberate exception to plain-HTML inputs
convention in CLAUDE.md and developer checklist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 11:58:14 +02:00
parent ea786f5341
commit 9a4d59eb4c
14 changed files with 695 additions and 100 deletions

View File

@@ -166,6 +166,7 @@ docker/ # Dockerfiles, Nginx configs, PHP configs
- New views must handle loading, empty, error, and success states (GR-UI-014)
- All visible text wrapped in `t('...')`
- Check existing JS/CSS components before adding new ones (GR-UI-REUSE)
- **File uploads** use `app-file-upload.phtml` partial (deliberate exception to plain-HTML inputs — the markup is too complex for inline use). See `templates/partials/app-file-upload.phtml` for the `$fileUpload` array contract. Never write file-upload markup inline; always use the partial.
### Never Do This

View File

@@ -17,6 +17,7 @@ return [
'css/components/app-forms.css',
'css/components/app-flash.css',
'css/components/app-brand.css',
'css/components/app-file-upload.css',
],
'default' => [
'css/vendor-overrides/multi-select.css',

View File

@@ -21,6 +21,7 @@ Kurze Definition of Done vor Merge.
- [ ] Listen-Filter konsistent: IDs/Enums als Select/MultiSelect statt Freitext
- [ ] Für persistierte Status/Outcomes nur zentrale Taxonomien aus `lib/Domain/Taxonomy/*` nutzen (keine lokalen String-Literale)
- [ ] Für neue/angepasste Liste: `filter-schema.php` angelegt/aktualisiert
- [ ] File-Uploads über `templates/partials/app-file-upload.phtml` (kein inline File-Upload-Markup) — `$fileUpload`-Array mit `name`, `accept`, `hint`, optional `currentSrc`/`deleteAction`
- [ ] Listen-Titlebar über `templates/partials/app-list-titlebar.phtml` (kein inline `<div class="app-list-titlebar">`)
- [ ] Listen-Tabs über `templates/partials/app-list-tabs.phtml` (kein inline `<div class="app-list-tabs">`)
- [ ] Purge-Titlebar-Action über `templates/partials/app-list-purge-action.phtml` (kein inline Purge-Form/Button)

View File

@@ -1430,5 +1430,10 @@
"Username or password not valid": "Benutzername oder Passwort ungültig",
"Write-only. Leave empty to keep current value.": "Nur Schreibzugriff. Leer lassen, um den aktuellen Wert beizubehalten.",
"Your account is deactivated.": "Ihr Konto ist deaktiviert.",
"LDAP": "LDAP"
"LDAP": "LDAP",
"Drop file here or click to select": "Datei hierher ziehen oder klicken",
"Remove selected file": "Ausgewaehlte Datei entfernen",
"Replace": "Ersetzen",
"Delete this image?": "Dieses Bild löschen?",
"Current image": "Aktuelles Bild"
}

View File

@@ -1430,5 +1430,10 @@
"Username or password not valid": "Username or password not valid",
"Write-only. Leave empty to keep current value.": "Write-only. Leave empty to keep current value.",
"Your account is deactivated.": "Your account is deactivated.",
"LDAP": "LDAP"
"LDAP": "LDAP",
"Drop file here or click to select": "Drop file here or click to select",
"Remove selected file": "Remove selected file",
"Replace": "Replace",
"Delete this image?": "Delete this image?",
"Current image": "Current image"
}

View File

@@ -87,13 +87,16 @@ $renderErrorTable = static function (array $rows): void {
<input type="hidden" name="<?php e($csrfKey); ?>" value="<?php e($csrfToken); ?>">
<input type="hidden" name="stage" value="analyze">
<input type="hidden" name="type" value="<?php e($selectedType); ?>">
<div class="grid">
<label>
<span><?php e(t('CSV file')); ?></span>
<input type="file" name="csv_file" accept=".csv,.txt,text/csv" required>
<small><?php e(t('Max size 10MB, max 20000 rows')); ?></small>
</label>
</div>
<span><?php e(t('CSV file')); ?></span>
<?php
$fileUpload = [
'name' => 'csv_file',
'accept' => '.csv,.txt,text/csv',
'hint' => t('Max size 10MB, max 20000 rows'),
'required' => true,
];
require templatePath('partials/app-file-upload.phtml');
?>
<button type="submit"><?php e(t('Analyze file')); ?></button>
</form>
</div>

View File

@@ -770,24 +770,20 @@ $disabledAttr = $isReadOnly ? 'disabled' : '';
<?php e(t('Upload logo')); ?>
</summary>
<hr>
<small>
<?php e(t('Allowed file types: SVG, PNG, JPG, WEBP')); ?>
</small>
<hr>
<form class="user-avatar-form" method="post" action="admin/settings/logo" enctype="multipart/form-data">
<label class="user-avatar-upload">
<input type="file" name="logo" accept="image/svg+xml,image/png,image/jpeg,image/webp">
</label>
<div class="grid">
<button type="submit" class="primary">
<?php e(t('Save')); ?>
</button>
<?php if ($hasLogo): ?>
<button type="submit" class="danger" formaction="admin/settings/logo-delete" formmethod="post">
<?php e(t('Remove logo')); ?>
</button>
<?php endif; ?>
</div>
<?php
$fileUpload = [
'name' => 'logo',
'accept' => 'image/svg+xml,image/png,image/jpeg,image/webp',
'hint' => t('Allowed file types: SVG, PNG, JPG, WEBP'),
'currentSrc' => $hasLogo ? appLogoUrl(128) : '',
'deleteAction' => $hasLogo ? 'admin/settings/logo-delete' : '',
];
require templatePath('partials/app-file-upload.phtml');
?>
<button type="submit" class="primary">
<?php e(t('Save')); ?>
</button>
<?php Session::getCsrfInput(); ?>
</form>
</details>
@@ -799,31 +795,23 @@ $disabledAttr = $isReadOnly ? 'disabled' : '';
<?php e(t('Upload favicon')); ?>
</summary>
<hr>
<?php if ($hasFavicon): ?>
<div class="entity-avatar-block avatar-square avatar-borderless" style="--avatar-size: 48px;">
<img class="entity-avatar-image" src="<?php e(asset('favicon/favicon-32x32.png')); ?>"
alt="<?php e(t('Favicon')); ?>">
</div>
<small><?php e(t('Favicon preview')); ?></small>
<hr>
<?php endif; ?>
<small><?php e(t('Allowed file types: PNG')); ?></small>
<small><?php e(t('Square images are recommended (icons are center-cropped).')); ?></small>
<hr>
<form class="user-avatar-form" method="post" action="admin/settings/favicon" enctype="multipart/form-data">
<label class="user-avatar-upload">
<input type="file" name="favicon" accept="image/png">
</label>
<div class="grid">
<button type="submit" class="primary">
<?php e(t('Save')); ?>
</button>
<?php if ($hasFavicon): ?>
<button type="submit" class="danger" formaction="admin/settings/favicon-delete" formmethod="post">
<?php e(t('Remove favicon')); ?>
</button>
<?php endif; ?>
</div>
<?php
$fileUpload = [
'name' => 'favicon',
'accept' => 'image/png',
'hint' => t('Allowed file types: PNG'),
'currentSrc' => $hasFavicon ? asset('favicon/favicon-32x32.png') : '',
'currentLabel' => t('Favicon'),
'deleteAction' => $hasFavicon ? 'admin/settings/favicon-delete' : '',
];
require templatePath('partials/app-file-upload.phtml');
?>
<button type="submit" class="primary">
<?php e(t('Save')); ?>
</button>
<?php Session::getCsrfInput(); ?>
</form>
</details>

View File

@@ -104,20 +104,19 @@ $hasFavicon = $avatarUuid !== '' && $tenantFaviconService->hasFavicon($avatarUui
<hr>
<form class="user-avatar-form" method="post" action="admin/tenants/avatar/<?php e($avatarUuid); ?>"
enctype="multipart/form-data">
<label class="user-avatar-upload">
<input type="file" name="avatar" accept="image/*">
</label>
<div class="grid">
<button type="submit" class="app-action-success">
<?php e(t('Save')); ?>
</button>
<?php if ($hasAvatar): ?>
<button data-tooltip-pos="top" data-tooltip="<?php e(t('Remove image')); ?>" type="submit" class="danger" formaction="admin/tenants/avatar-delete/<?php e($avatarUuid); ?>"
formmethod="post">
<i class="bi bi-trash3-fill"></i>
</button>
<?php endif; ?>
</div>
<?php
$fileUpload = [
'name' => 'avatar',
'accept' => 'image/*',
'hint' => t('Allowed file types: SVG, PNG, JPG, WEBP'),
'currentSrc' => $hasAvatar ? 'admin/tenants/avatar-file?uuid=' . $avatarUuid . '&size=128' : '',
'deleteAction' => $hasAvatar ? 'admin/tenants/avatar-delete/' . $avatarUuid : '',
];
require templatePath('partials/app-file-upload.phtml');
?>
<button type="submit" class="app-action-success">
<?php e(t('Save')); ?>
</button>
<?php Session::getCsrfInput(); ?>
</form>
</details>
@@ -129,33 +128,24 @@ $hasFavicon = $avatarUuid !== '' && $tenantFaviconService->hasFavicon($avatarUui
<?php e(t('Upload favicon')); ?>
</summary>
<hr>
<?php if ($hasFavicon): ?>
<div class="entity-avatar-block avatar-square avatar-borderless" style="--avatar-size: 48px;">
<img class="entity-avatar-image"
src="<?php e(asset('favicon/tenants/' . $avatarUuid . '/favicon/favicon-32x32.png')); ?>"
alt="<?php e(t('Favicon')); ?>">
</div>
<small><?php e(t('Favicon preview')); ?></small>
<hr>
<?php endif; ?>
<small><?php e(t('Allowed file types: PNG')); ?></small>
<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($avatarUuid); ?>"
enctype="multipart/form-data">
<label class="user-avatar-upload">
<input type="file" name="favicon" accept="image/png">
</label>
<div class="grid">
<button type="submit" class="app-action-success">
<?php e(t('Save')); ?>
</button>
<?php if ($hasFavicon): ?>
<button type="submit" class="danger" formaction="admin/tenants/favicon-delete/<?php e($avatarUuid); ?>" formmethod="post">
<?php e(t('Remove favicon')); ?>
</button>
<?php endif; ?>
</div>
<?php
$fileUpload = [
'name' => 'favicon',
'accept' => 'image/png',
'hint' => t('Allowed file types: PNG'),
'currentSrc' => $hasFavicon ? asset('favicon/tenants/' . $avatarUuid . '/favicon/favicon-32x32.png') : '',
'currentLabel' => t('Favicon'),
'deleteAction' => $hasFavicon ? 'admin/tenants/favicon-delete/' . $avatarUuid : '',
];
require templatePath('partials/app-file-upload.phtml');
?>
<button type="submit" class="app-action-success">
<?php e(t('Save')); ?>
</button>
<?php Session::getCsrfInput(); ?>
</form>
</details>

View File

@@ -252,20 +252,19 @@ if ($lastLoginAt !== '') {
<hr>
<form class="user-avatar-form" method="post" action="admin/users/avatar/<?php e($avatarUuid); ?>"
enctype="multipart/form-data">
<label class="user-avatar-upload">
<input type="file" name="avatar" accept="image/*">
</label>
<div class="grid">
<button type="submit" class="app-action-success">
<?php e(t('Save')); ?>
</button>
<?php if ($hasAvatar): ?>
<button data-tooltip-pos="top" data-tooltip="<?php e(t('Remove image')); ?>" type="submit" class="danger" formaction="admin/users/avatar-delete/<?php e($avatarUuid); ?>"
formmethod="post">
<i class="bi bi-trash3-fill"></i>
</button>
<?php endif; ?>
</div>
<?php
$fileUpload = [
'name' => 'avatar',
'accept' => 'image/*',
'hint' => t('Allowed file types: SVG, PNG, JPG, WEBP'),
'currentSrc' => $hasAvatar ? 'admin/users/avatar-file?uuid=' . $avatarUuid . '&size=128' : '',
'deleteAction' => $hasAvatar ? 'admin/users/avatar-delete/' . $avatarUuid : '',
];
require templatePath('partials/app-file-upload.phtml');
?>
<button type="submit" class="app-action-success">
<?php e(t('Save')); ?>
</button>
<?php Session::getCsrfInput(); ?>
</form>
</details>

View File

@@ -0,0 +1,79 @@
<?php
/**
* Shared file-upload component — card-based dropzone with drag-and-drop, current file preview, and pending preview.
*
* This is a deliberate exception to the "plain HTML inputs" convention: the file-upload markup is complex enough
* (3 states, nested elements, data attributes, confirm dialog, i18n) that a partial prevents drift across call sites.
*
* Usage:
* $fileUpload = [
* '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
* 'currentLabel' => t('Current image'), // optional — label for current file preview
* 'deleteAction' => 'admin/tenants/avatar-delete/…', // optional — formaction for delete button (requires currentSrc)
* 'deleteConfirm' => t('Delete this image?'), // optional — confirm message on delete
* 'required' => false, // optional — make input required
* ];
* require templatePath('partials/app-file-upload.phtml');
*
* @var array<string, mixed> $fileUpload
*/
$fileUpload = is_array($fileUpload ?? null) ? $fileUpload : [];
$uploadName = trim((string) ($fileUpload['name'] ?? ''));
if ($uploadName === '') {
return;
}
$uploadAccept = trim((string) ($fileUpload['accept'] ?? ''));
$uploadHint = trim((string) ($fileUpload['hint'] ?? ''));
$uploadCurrentSrc = trim((string) ($fileUpload['currentSrc'] ?? ''));
$uploadCurrentLabel = trim((string) ($fileUpload['currentLabel'] ?? t('Current image')));
$uploadDeleteAction = trim((string) ($fileUpload['deleteAction'] ?? ''));
$uploadDeleteConfirm = trim((string) ($fileUpload['deleteConfirm'] ?? t('Delete this image?')));
$uploadRequired = (bool) ($fileUpload['required'] ?? false);
$hasCurrent = $uploadCurrentSrc !== '';
$hasDelete = $hasCurrent && $uploadDeleteAction !== '';
?>
<div data-app-component="file-upload" class="app-file-upload"
<?php if ($hasCurrent): ?>data-current-src="<?php e($uploadCurrentSrc); ?>" data-current-label="<?php e($uploadCurrentLabel); ?>"<?php endif; ?>>
<?php if ($hasCurrent): ?>
<div class="app-file-upload-current">
<img class="app-file-upload-current-image" src="" alt="">
<div class="app-file-upload-current-meta">
<span class="app-file-upload-current-label"></span>
<span class="app-file-upload-current-actions">
<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"
data-confirm-message="<?php e($uploadDeleteConfirm); ?>"><?php e(t('Delete')); ?></button>
<?php endif; ?>
</span>
</div>
</div>
<?php endif; ?>
<label class="app-file-upload-dropzone">
<input type="file" name="<?php e($uploadName); ?>"
<?php if ($uploadAccept !== ''): ?>accept="<?php e($uploadAccept); ?>"<?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>
<?php if ($uploadHint !== ''): ?>
<span class="app-file-upload-dropzone-hint"><?php e($uploadHint); ?></span>
<?php endif; ?>
</label>
<div class="app-file-upload-preview">
<img class="app-file-upload-thumbnail" alt="" hidden>
<span class="app-file-upload-file-icon" hidden><i class="bi bi-file-earmark"></i></span>
<span class="app-file-upload-meta">
<span class="app-file-upload-filename"></span>
<span class="app-file-upload-filesize"></span>
</span>
<button type="button" class="app-file-upload-clear" aria-label="<?php e(t('Remove selected file')); ?>"><i class="bi bi-x-lg"></i></button>
</div>
</div>

View File

@@ -285,7 +285,7 @@ $modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleS
<div class="app-sidebar-tenant-logo">
<a href="<?php e(lurl('')); ?>">
<?php if ($hasTenantAvatar): ?>
<img src="auth/tenant-avatar-file?uuid=<?php e($tenantUuid); ?>&size=128" alt="<?php e($tenantName); ?>"
<img src="auth/tenant-avatar-file?uuid=<?php e($tenantUuid); ?>&size=256" alt="<?php e($tenantName); ?>"
title="<?php e($tenantName); ?>">
<?php else: ?>
<div class="app-sidebar-tenant-name">

View File

@@ -0,0 +1,245 @@
@layer components {
/* File upload — card-based dropzone with drag-and-drop, current file preview, and pending file preview. */
.app-file-upload {
position: relative;
border: 1px solid var(--app-muted-border-color);
border-radius: var(--app-border-radius);
background: var(--app-card-background-color);
overflow: hidden;
transition: border-color 0.15s ease;
margin-bottom: var(--app-spacing);
}
.app-file-upload.is-dragover {
border-color: var(--app-primary);
background: color-mix(in srgb, var(--app-primary) 5%, var(--app-card-background-color));
}
/* ── Current file (server-side, shown when data-current-src is set) ── */
.app-file-upload-current {
display: flex;
align-items: center;
gap: calc(var(--app-spacing) * 0.75);
padding: calc(var(--app-spacing) * 0.75);
}
.app-file-upload-current-image {
width: 48px;
height: 48px;
border-radius: calc(var(--app-border-radius) * 0.5);
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--app-muted-border-color);
background: var(--app-background-color);
}
.app-file-upload-current-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.app-file-upload-current-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--app-contrast);
}
.app-file-upload-current-actions {
display: flex;
gap: calc(var(--app-spacing) * 0.25);
}
.app-file-upload-current-actions button {
background: none;
border: none;
padding: calc(var(--app-spacing) * 0.15) calc(var(--app-spacing) * 0.35);
cursor: pointer;
font-size: var(--text-xs);
border-radius: var(--app-border-radius);
transition: color 0.15s ease, background 0.15s ease;
margin: 0;
width: auto;
line-height: var(--leading-normal);
}
.app-file-upload-replace-button {
color: var(--app-primary);
}
.app-file-upload-replace-button:hover {
background: color-mix(in srgb, var(--app-primary) 10%, transparent);
}
.app-file-upload-delete-button {
color: var(--app-del-color);
}
.app-file-upload-delete-button:hover {
background: color-mix(in srgb, var(--app-del-color) 10%, transparent);
}
.app-file-upload.has-current .app-file-upload-dropzone {
display: none;
}
.app-file-upload:not(.has-current) .app-file-upload-current {
display: none;
}
/* ── Dropzone (visible when no current file and no pending file) ── */
.app-file-upload-dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: calc(var(--app-spacing) * 0.5);
padding: calc(var(--app-spacing) * 1.5) var(--app-spacing);
cursor: pointer;
text-align: center;
color: var(--app-muted-color);
border: 2px dashed var(--app-muted-border-color);
border-radius: var(--app-border-radius);
margin: calc(var(--app-spacing) * 0.5);
transition: border-color 0.15s ease, color 0.15s ease;
}
.app-file-upload.is-dragover .app-file-upload-dropzone {
border-color: var(--app-primary);
color: var(--app-primary);
}
.app-file-upload-dropzone:hover {
border-color: var(--app-primary);
color: var(--app-primary);
}
.app-file-upload-dropzone-icon {
font-size: var(--text-2xl);
line-height: var(--leading-none);
}
.app-file-upload-dropzone-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.app-file-upload-dropzone-hint {
font-size: var(--text-xs);
opacity: 0.7;
}
.app-file-upload.has-file .app-file-upload-dropzone,
.app-file-upload.has-file .app-file-upload-current {
display: none;
}
.app-file-upload.is-rejected {
animation: file-upload-shake 0.4s ease;
}
.app-file-upload.is-rejected .app-file-upload-dropzone {
border-color: var(--app-del-color);
color: var(--app-del-color);
}
@keyframes file-upload-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
}
/* ── Pending file preview (visible when new file selected) ── */
.app-file-upload-preview {
display: none;
align-items: center;
gap: calc(var(--app-spacing) * 0.75);
padding: calc(var(--app-spacing) * 0.75);
}
.app-file-upload.has-file .app-file-upload-preview {
display: flex;
}
.app-file-upload-thumbnail {
width: 48px;
height: 48px;
border-radius: calc(var(--app-border-radius) * 0.5);
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--app-muted-border-color);
background: var(--app-background-color);
}
.app-file-upload-file-icon {
width: 48px;
height: 48px;
border-radius: calc(var(--app-border-radius) * 0.5);
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--text-xl);
color: var(--app-muted-color);
background: var(--app-background-color);
border: 1px solid var(--app-muted-border-color);
}
.app-file-upload-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.app-file-upload-filename {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--app-contrast);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-file-upload-filesize {
font-size: var(--text-xs);
color: var(--app-muted-color);
}
.app-file-upload-clear {
flex-shrink: 0;
background: none;
border: none;
padding: calc(var(--app-spacing) * 0.25);
cursor: pointer;
color: var(--app-muted-color);
font-size: var(--text-base);
line-height: var(--leading-none);
border-radius: var(--app-border-radius);
transition: color 0.15s ease;
margin: 0;
width: auto;
}
.app-file-upload-clear:hover {
color: var(--app-del-color);
}
/* ── Hidden native input ── */
.app-file-upload input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
}

View File

@@ -24,6 +24,7 @@ import { initAutoSubmit } from './components/app-auto-submit.js';
import { initTabs } from './components/app-tabs.js';
import { initSessionWarning } from './components/app-session-warning.js';
import { initFsLightboxRefresh } from './components/app-fslightbox-refresh.js';
import { initFileUpload } from './components/app-file-upload.js';
const runtime = createComponentRuntime({
root: document,
@@ -188,6 +189,10 @@ const bootRuntime = async () => {
scope: 'global',
configPath: 'components.autoSubmit',
});
runtime.register('file-upload', initFileUpload, {
selector: '[data-app-component="file-upload"]',
configPath: 'components.fileUpload',
});
await mountModuleComponentsByPhase('early');
// Phase B: structure-defining components.

View File

@@ -0,0 +1,273 @@
/**
* File upload — card-based dropzone with drag-and-drop, current file preview, and pending file preview.
*
* Markup contract:
* <div data-app-component="file-upload" class="app-file-upload"
* data-current-src="…" data-current-label="…"> <!-- optional: existing server file -->
* <div class="app-file-upload-current"> <!-- shown when has existing file -->
* <img class="app-file-upload-current-image" src="" alt="">
* <div class="app-file-upload-current-meta">
* <span class="app-file-upload-current-label"></span>
* <span class="app-file-upload-current-actions">
* <button type="button" class="app-file-upload-replace-button">Replace</button>
* <button type="submit" class="app-file-upload-delete-button"
* formaction="…" formmethod="post"
* data-confirm-message="…">Delete</button>
* </span>
* </div>
* </div>
* <label class="app-file-upload-dropzone"> <!-- shown when no file -->
* <input type="file" name="…" accept="…">
* <span class="app-file-upload-dropzone-icon">…</span>
* <span class="app-file-upload-dropzone-label">…</span>
* <span class="app-file-upload-dropzone-hint">…</span>
* </label>
* <div class="app-file-upload-preview"> <!-- shown when new file selected -->
* <img class="app-file-upload-thumbnail" hidden>
* <span class="app-file-upload-file-icon" hidden>…</span>
* <span class="app-file-upload-meta">
* <span class="app-file-upload-filename"></span>
* <span class="app-file-upload-filesize"></span>
* </span>
* <button type="button" class="app-file-upload-clear">…</button>
* </div>
* </div>
*/
const MAX_PREVIEW_SIZE = 10 * 1024 * 1024; // 10 MB
const isImageFile = (file) => file.type.startsWith('image/');
const formatFileSize = (bytes) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
export function initFileUpload(root) {
if (!(root instanceof HTMLElement)) {
return { destroy: () => {} };
}
if (root.dataset.fileUploadBound === '1') {
return { destroy: () => {} };
}
root.dataset.fileUploadBound = '1';
const input = root.querySelector('input[type="file"]');
if (!input) {
return { destroy: () => {} };
}
const thumbnail = root.querySelector('.app-file-upload-thumbnail');
const fileIcon = root.querySelector('.app-file-upload-file-icon');
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 cleanupFns = [];
let dragCounter = 0;
// ── Initialize current file state from data attributes ──
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) => {
root.classList.add('has-file');
root.classList.remove('has-current');
if (filenameEl) {
filenameEl.textContent = file.name;
}
if (filesizeEl) {
filesizeEl.textContent = formatFileSize(file.size);
}
if (thumbnail) {
thumbnail.hidden = true;
}
if (fileIcon) {
fileIcon.hidden = true;
}
if (isImageFile(file) && file.size <= MAX_PREVIEW_SIZE && thumbnail) {
const reader = new FileReader();
reader.onload = (e) => {
thumbnail.src = /** @type {string} */ (e.target?.result ?? '');
thumbnail.hidden = false;
if (fileIcon) {
fileIcon.hidden = true;
}
};
reader.onerror = () => {
if (fileIcon) {
fileIcon.hidden = false;
}
};
reader.readAsDataURL(file);
} else if (fileIcon) {
fileIcon.hidden = false;
}
};
// ── Clear pending file → go back to current (if exists) or dropzone ──
const clearFile = () => {
root.classList.remove('has-file');
input.value = '';
if (thumbnail) {
thumbnail.src = '';
thumbnail.hidden = true;
}
if (fileIcon) {
fileIcon.hidden = true;
}
if (filenameEl) {
filenameEl.textContent = '';
}
if (filesizeEl) {
filesizeEl.textContent = '';
}
// Restore current state if a server file exists
if (currentSrc) {
root.classList.add('has-current');
}
};
// ── Input change ──
const onInputChange = () => {
const file = input.files?.[0];
if (file) {
showFile(file);
} else {
clearFile();
}
};
input.addEventListener('change', onInputChange);
cleanupFns.push(() => input.removeEventListener('change', onInputChange));
// ── Clear button ──
if (clearButton) {
const onClear = (e) => {
e.preventDefault();
e.stopPropagation();
clearFile();
};
clearButton.addEventListener('click', onClear);
cleanupFns.push(() => clearButton.removeEventListener('click', onClear));
}
// ── Replace button → open file dialog ──
if (replaceButton) {
const onReplace = (e) => {
e.preventDefault();
e.stopPropagation();
input.click();
};
replaceButton.addEventListener('click', onReplace);
cleanupFns.push(() => replaceButton.removeEventListener('click', onReplace));
}
// ── Drag and drop ──
const onDragEnter = (e) => {
e.preventDefault();
dragCounter++;
root.classList.add('is-dragover');
};
const onDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
};
const onDragLeave = (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
root.classList.remove('is-dragover');
}
};
const onDrop = (e) => {
e.preventDefault();
dragCounter = 0;
root.classList.remove('is-dragover');
const file = e.dataTransfer?.files?.[0];
if (!file) {
return;
}
// Validate against accept attribute
const accept = input.accept;
if (accept && !fileMatchesAccept(file, accept)) {
root.classList.add('is-rejected');
setTimeout(() => root.classList.remove('is-rejected'), 600);
return;
}
// Transfer file to the native input via DataTransfer
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
showFile(file);
};
root.addEventListener('dragenter', onDragEnter);
root.addEventListener('dragover', onDragOver);
root.addEventListener('dragleave', onDragLeave);
root.addEventListener('drop', onDrop);
cleanupFns.push(() => {
root.removeEventListener('dragenter', onDragEnter);
root.removeEventListener('dragover', onDragOver);
root.removeEventListener('dragleave', onDragLeave);
root.removeEventListener('drop', onDrop);
});
const destroy = () => {
cleanupFns.forEach((fn) => fn());
cleanupFns.length = 0;
delete root.dataset.fileUploadBound;
};
return { destroy };
}
/**
* Check if a File matches an accept attribute value.
* Supports MIME types (image/*), extensions (.csv), and exact types (image/png).
*/
function fileMatchesAccept(file, accept) {
const tokens = accept.split(',').map((t) => t.trim().toLowerCase());
const fileName = file.name.toLowerCase();
const fileType = file.type.toLowerCase();
return tokens.some((token) => {
if (token.startsWith('.')) {
return fileName.endsWith(token);
}
if (token.endsWith('/*')) {
return fileType.startsWith(token.slice(0, -1));
}
return fileType === token;
});
}