diff --git a/src/app/components/image-uploader/image-uploader.html b/src/app/components/image-uploader/image-uploader.html
new file mode 100644
index 0000000..b2e86a3
--- /dev/null
+++ b/src/app/components/image-uploader/image-uploader.html
@@ -0,0 +1,20 @@
+@if (imgUrl()) {
+
+
![]()
+
+}
+
+
+
+ {{ fileName() || 'common.no_file_yet' | translate | upperfirst }}
+
+
+
diff --git a/src/app/components/image-uploader/image-uploader.scss b/src/app/components/image-uploader/image-uploader.scss
new file mode 100644
index 0000000..7650d8a
--- /dev/null
+++ b/src/app/components/image-uploader/image-uploader.scss
@@ -0,0 +1,26 @@
+.image-preview {
+ &__container {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 16px;
+ }
+}
+
+.file-input {
+ display: none;
+}
+
+.file-upload {
+ align-items: center;
+ box-sizing: border-box;
+ display: inline-flex;
+ justify-content: space-between;
+ min-width: 196px;
+ width: 100%;
+
+ &__description {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
diff --git a/src/app/components/image-uploader/image-uploader.spec.ts b/src/app/components/image-uploader/image-uploader.spec.ts
new file mode 100644
index 0000000..1a16966
--- /dev/null
+++ b/src/app/components/image-uploader/image-uploader.spec.ts
@@ -0,0 +1,69 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ImageUploader } from './image-uploader';
+import { provideTranslateService } from '@ngx-translate/core';
+import { By } from '@angular/platform-browser';
+import { FiletypeUtils } from '../../services/filetype-utils';
+
+describe('ImageUploader', () => {
+ let component: ImageUploader;
+ let fixture: ComponentFixture;
+
+ let filetypeUtils: Partial;
+
+ beforeEach(async () => {
+ filetypeUtils = {
+ isValidImageMimeType: vi.fn().mockReturnValue(true)
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [ImageUploader],
+ providers: [provideTranslateService(), { provide: FiletypeUtils, useValue: filetypeUtils }],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ImageUploader);
+ component = fixture.componentInstance;
+ await fixture.whenStable();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should update selection when fileIn input changes', async () => {
+ const file = new File([''], 'test.png', { type: 'image/png' });
+ fixture.componentRef.setInput('fileIn', file);
+ fixture.detectChanges();
+ const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description'));
+ expect((descriptionEl.nativeElement).textContent.toLowerCase().trim()).toEqual(
+ file.name,
+ );
+ });
+
+ describe('onFileSelected', () => {
+ it('should update and emit selection on valid file', () => {
+ const emitSpy = vi.spyOn(component.file, 'emit');
+ const file = new File([''], 'test.png', { type: 'image/png' });
+ const inputEvent = { target: { files: [file] } } as any;
+ component.onFileSelected(inputEvent);
+ fixture.detectChanges();
+ const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description'));
+ expect(
+ (descriptionEl.nativeElement).textContent.toLowerCase().trim(),
+ ).toEqual(file.name);
+ expect(emitSpy).toHaveBeenCalledOnce();
+ });
+
+ it('should discard file if not valid type', () => {
+ filetypeUtils.isValidImageMimeType = vi.fn().mockReturnValue(false);
+ const file = new File([''], 'test.txt', { type: 'text/txt' });
+ const inputEvent = { target: { files: [file] } } as any;
+ component.onFileSelected(inputEvent);
+ fixture.detectChanges();
+ const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description'));
+ expect(
+ (descriptionEl.nativeElement).textContent.toLowerCase().trim(),
+ ).not.toEqual(file.name);
+ });
+ });
+});
diff --git a/src/app/components/image-uploader/image-uploader.ts b/src/app/components/image-uploader/image-uploader.ts
new file mode 100644
index 0000000..2e3c105
--- /dev/null
+++ b/src/app/components/image-uploader/image-uploader.ts
@@ -0,0 +1,45 @@
+import { Component, effect, inject, input, output, signal } from '@angular/core';
+import { MatMiniFabButton } from '@angular/material/button';
+import { MatIcon } from '@angular/material/icon';
+import { TranslatePipe } from '@ngx-translate/core';
+import { UpperfirstPipe } from '../../pipes/upperfirst-pipe';
+import { FiletypeUtils } from '../../services/filetype-utils';
+
+@Component({
+ selector: 'app-image-uploader',
+ imports: [MatIcon, MatMiniFabButton, TranslatePipe, UpperfirstPipe],
+ templateUrl: './image-uploader.html',
+ styleUrl: './image-uploader.scss',
+})
+export class ImageUploader {
+ file = output();
+ fileIn = input(null);
+ protected readonly fileName = signal('');
+ protected readonly imgUrl = signal('');
+ private readonly filetypeUtils = inject(FiletypeUtils);
+
+ constructor() {
+ effect(() => {
+ if (this.fileIn()) {
+ const file = this.fileIn();
+ this.updateSelection(file);
+ }
+ });
+ }
+
+ onFileSelected(e: Partial) {
+ const files = (e.target).files;
+ if (files) {
+ const file = files[0];
+ if (file && this.filetypeUtils.isValidImageMimeType(file.type)) {
+ this.updateSelection(file);
+ this.file.emit(file);
+ }
+ }
+ }
+
+ private updateSelection(file: File) {
+ this.fileName.set(file.name);
+ this.imgUrl.set(URL.createObjectURL(file));
+ }
+}