diff --git a/CLAUDE.md b/CLAUDE.md
index c782956..34fdf1c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/config/assets.php b/config/assets.php
index 6d6b865..c598da6 100644
--- a/config/assets.php
+++ b/config/assets.php
@@ -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',
diff --git a/docs/reference-entwickler-checkliste.md b/docs/reference-entwickler-checkliste.md
index 6bfefa2..3260164 100644
--- a/docs/reference-entwickler-checkliste.md
+++ b/docs/reference-entwickler-checkliste.md
@@ -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 `
`)
- [ ] Listen-Tabs über `templates/partials/app-list-tabs.phtml` (kein inline `
`)
- [ ] Purge-Titlebar-Action über `templates/partials/app-list-purge-action.phtml` (kein inline Purge-Form/Button)
diff --git a/i18n/default_de.json b/i18n/default_de.json
index c39174c..339a2b6 100644
--- a/i18n/default_de.json
+++ b/i18n/default_de.json
@@ -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"
}
diff --git a/i18n/default_en.json b/i18n/default_en.json
index bfe620f..7dc456a 100644
--- a/i18n/default_en.json
+++ b/i18n/default_en.json
@@ -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"
}
diff --git a/pages/admin/imports/index(default).phtml b/pages/admin/imports/index(default).phtml
index fae6e16..ac328bd 100644
--- a/pages/admin/imports/index(default).phtml
+++ b/pages/admin/imports/index(default).phtml
@@ -87,13 +87,16 @@ $renderErrorTable = static function (array $rows): void {
-
-
-
+
+ 'csv_file',
+ 'accept' => '.csv,.txt,text/csv',
+ 'hint' => t('Max size 10MB, max 20000 rows'),
+ 'required' => true,
+ ];
+ require templatePath('partials/app-file-upload.phtml');
+ ?>
diff --git a/pages/admin/settings/index(default).phtml b/pages/admin/settings/index(default).phtml
index 51e85d4..0dc1462 100644
--- a/pages/admin/settings/index(default).phtml
+++ b/pages/admin/settings/index(default).phtml
@@ -770,24 +770,20 @@ $disabledAttr = $isReadOnly ? 'disabled' : '';
-
-
-
-
@@ -799,31 +795,23 @@ $disabledAttr = $isReadOnly ? 'disabled' : '';
-
-
-
); ?>)
-
-
-
-
-
diff --git a/pages/admin/tenants/edit(default).phtml b/pages/admin/tenants/edit(default).phtml
index 9c75aa2..cc473ec 100644
--- a/pages/admin/tenants/edit(default).phtml
+++ b/pages/admin/tenants/edit(default).phtml
@@ -104,20 +104,19 @@ $hasFavicon = $avatarUuid !== '' && $tenantFaviconService->hasFavicon($avatarUui
@@ -129,33 +128,24 @@ $hasFavicon = $avatarUuid !== '' && $tenantFaviconService->hasFavicon($avatarUui
-
-
-
); ?>)
-
-
-
-
-
diff --git a/pages/admin/users/edit(default).phtml b/pages/admin/users/edit(default).phtml
index 5d3f7e0..c124cfb 100644
--- a/pages/admin/users/edit(default).phtml
+++ b/pages/admin/users/edit(default).phtml
@@ -252,20 +252,19 @@ if ($lastLoginAt !== '') {
diff --git a/templates/partials/app-file-upload.phtml b/templates/partials/app-file-upload.phtml
new file mode 100644
index 0000000..e223999
--- /dev/null
+++ b/templates/partials/app-file-upload.phtml
@@ -0,0 +1,79 @@
+ '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
$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 !== '';
+
+?>
+data-current-src="" data-current-label="">
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
diff --git a/templates/partials/app-main-aside.phtml b/templates/partials/app-main-aside.phtml
index 3affa99..7ba69b7 100644
--- a/templates/partials/app-main-aside.phtml
+++ b/templates/partials/app-main-aside.phtml
@@ -285,7 +285,7 @@ $modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleS