diff --git a/webapp/.prettierrc b/webapp/.prettierrc
index 3cc88595..c188b0b1 100644
--- a/webapp/.prettierrc
+++ b/webapp/.prettierrc
@@ -6,5 +6,5 @@
"semi": true,
"bracketSpacing": true,
"trailingComma": "none",
- "endOfLine": "lf"
+ "endOfLine": "auto"
}
diff --git a/webapp/src/app/ui/avatar/avatar.component.html b/webapp/src/app/ui/avatar/avatar.component.html
new file mode 100644
index 00000000..d57806aa
--- /dev/null
+++ b/webapp/src/app/ui/avatar/avatar.component.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/webapp/src/app/ui/avatar/avatar.component.ts b/webapp/src/app/ui/avatar/avatar.component.ts
new file mode 100644
index 00000000..d2eb5a09
--- /dev/null
+++ b/webapp/src/app/ui/avatar/avatar.component.ts
@@ -0,0 +1,51 @@
+import { Component, computed, input, signal } from '@angular/core';
+import type { ClassValue } from 'clsx';
+import type { VariantProps } from 'class-variance-authority';
+import { cn } from 'app/utils';
+import { cva } from 'app/storybook.helper';
+import { NgOptimizedImage } from '@angular/common';
+
+const [avatarVariants, args, argTypes] = cva('relative flex shrink-0 overflow-hidden rounded-full', {
+ variants: {
+ size: {
+ default: 'h-10 w-10',
+ sm: 'h-6 w-6 text-xs',
+ lg: 'h-14 w-14 text-lg'
+ }
+ },
+ defaultVariants: {
+ size: 'default'
+ }
+});
+
+export { args, argTypes };
+
+interface AvatarVariants extends VariantProps {}
+
+@Component({
+ selector: 'app-avatar',
+ standalone: true,
+ imports: [NgOptimizedImage],
+ templateUrl: './avatar.component.html'
+})
+export class AppAvatarComponent {
+ class = input('');
+ size = input('default');
+
+ src = input.required();
+ alt = input('');
+ imageClass = input('');
+ fallback = input('https://placehold.co/56');
+
+ canShow = signal(true);
+
+ onError = () => {
+ this.canShow.set(false);
+ };
+
+ computedClass = computed(() => cn(avatarVariants({ size: this.size() }), this.class()));
+
+ computedSrc = computed(() => (this.canShow() ? this.src() : this.fallback()));
+
+ computedImageClass = computed(() => cn('aspect-square object-cover h-full w-full', this.imageClass()));
+}
diff --git a/webapp/src/app/ui/avatar/avatar.stories.ts b/webapp/src/app/ui/avatar/avatar.stories.ts
new file mode 100644
index 00000000..2aadf187
--- /dev/null
+++ b/webapp/src/app/ui/avatar/avatar.stories.ts
@@ -0,0 +1,95 @@
+import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
+import { AppAvatarComponent, args, argTypes } from './avatar.component';
+
+const meta: Meta = {
+ title: 'UI/Avatar',
+ component: AppAvatarComponent,
+ tags: ['autodocs'],
+ args: {
+ ...args
+ },
+ argTypes: {
+ ...argTypes
+ }
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ src: 'https://i.pravatar.cc/40?img=1',
+ alt: 'avatar',
+ class: ''
+ },
+
+ render: (args) => ({
+ props: args,
+ template: ``
+ })
+};
+
+export const Small: Story = {
+ args: {
+ size: 'sm',
+ src: 'https://i.pravatar.cc/24?img=1'
+ },
+
+ render: (args) => ({
+ props: args,
+ template: ``
+ })
+};
+
+export const Medium: Story = {
+ args: {
+ size: 'default',
+ src: 'https://i.pravatar.cc/40?img=1'
+ },
+
+ render: (args) => ({
+ props: args,
+ template: ``
+ })
+};
+
+export const Large: Story = {
+ args: {
+ size: 'lg',
+ src: 'https://i.pravatar.cc/56?img=1',
+ alt: 'avatar',
+ class: ''
+ },
+
+ render: (args) => ({
+ props: args,
+ template: ``
+ })
+};
+
+export const WithRandomImage: Story = {
+ args: {
+ size: 'lg',
+ src: 'https://i.pravatar.cc/56',
+ alt: 'avatar'
+ },
+
+ render: (args) => ({
+ props: args,
+ template: ``
+ })
+};
+
+export const WithFallback: Story = {
+ args: {
+ size: 'default',
+ src: 'foobar.jpg',
+ fallback: 'https://placehold.co/40',
+ alt: 'fallback'
+ },
+
+ render: (args) => ({
+ props: args,
+ template: ``
+ })
+};