diff --git a/webapp/src/app/ui/avatar/avatar-config.ts b/webapp/src/app/ui/avatar/avatar-config.ts new file mode 100644 index 00000000..51e5ca52 --- /dev/null +++ b/webapp/src/app/ui/avatar/avatar-config.ts @@ -0,0 +1,24 @@ +import { inject, InjectionToken, Provider } from '@angular/core'; + +export interface AvatarConfig { + delayMs: number; +} + +export const defaultAvatarConfig: AvatarConfig = { + delayMs: 0 +}; + +export const AvatarConfigToken = new InjectionToken('AvatarConfigToken'); + +export function provideAvatarConfig(config: Partial): Provider[] { + return [ + { + provide: AvatarConfigToken, + useValue: { ...defaultAvatarConfig, ...config } + } + ]; +} + +export function injectAvatarConfig(): AvatarConfig { + return inject(AvatarConfigToken, { optional: true }) ?? defaultAvatarConfig; +} diff --git a/webapp/src/app/ui/avatar/avatar-fallback.component.ts b/webapp/src/app/ui/avatar/avatar-fallback.component.ts new file mode 100644 index 00000000..3948454c --- /dev/null +++ b/webapp/src/app/ui/avatar/avatar-fallback.component.ts @@ -0,0 +1,36 @@ +import { Component, computed, input, OnDestroy, OnInit, signal } from '@angular/core'; +import { cn } from 'app/utils'; +import { ClassValue } from 'clsx'; +import { injectAvatarConfig } from './avatar-config'; +import { injectAvatar } from './avatar.component'; + +@Component({ + selector: 'app-avatar-fallback', + standalone: true, + template: ``, + host: { + '[class]': 'computedClass()', + '[style.display]': 'visible() ? "flex" : "none"' + } +}) +export class AvatarFallbackComponent implements OnInit, OnDestroy { + private readonly avatar = injectAvatar(); + private config = injectAvatarConfig(); + private delayElapsed = signal(false); + private timeout: NodeJS.Timeout | null = null; + + class = input(); + delayMs = input(this.config.delayMs); + computedClass = computed(() => cn('absolute inset-0 flex items-center justify-center rounded-full bg-muted', this.class())); + visible = computed(() => this.avatar.state() !== 'loaded' && this.delayElapsed()); + + ngOnInit(): void { + this.timeout = setTimeout(() => this.delayElapsed.set(true), this.delayMs()); + } + + ngOnDestroy(): void { + if (this.timeout) { + clearTimeout(this.timeout); + } + } +} diff --git a/webapp/src/app/ui/avatar/avatar-image.component.ts b/webapp/src/app/ui/avatar/avatar-image.component.ts new file mode 100644 index 00000000..5be4a7cf --- /dev/null +++ b/webapp/src/app/ui/avatar/avatar-image.component.ts @@ -0,0 +1,48 @@ +import { NgOptimizedImage } from '@angular/common'; +import { AfterViewInit, Component, computed, ElementRef, input, OnInit, viewChild } from '@angular/core'; +import { ClassValue } from 'clsx'; +import { cn } from 'app/utils'; +import { injectAvatar } from './avatar.component'; + +@Component({ + selector: 'app-avatar-image', + standalone: true, + imports: [NgOptimizedImage], + template: ``, + host: { + '[class]': 'computedClass()', + '[style.display]': 'avatar.state() === "error" ? "none" : "inline"' + } +}) +export class AvatarImageComponent implements OnInit, AfterViewInit { + readonly avatar = injectAvatar(); + private readonly imgRef = viewChild>('imgEl'); + + class = input(); + src = input.required(); + alt = input(''); + + computedClass = computed(() => cn('aspect-square h-full w-full', this.class())); + + ngOnInit(): void { + this.avatar.setState('loading'); + } + + ngAfterViewInit(): void { + if (!this.imgRef()?.nativeElement.src) { + this.avatar.setState('error'); + } + + if (this.imgRef()?.nativeElement.complete) { + this.avatar.setState('loaded'); + } + } + + onLoad(): void { + this.avatar.setState('loaded'); + } + + onError(): void { + this.avatar.setState('error'); + } +} diff --git a/webapp/src/app/ui/avatar/avatar.component.html b/webapp/src/app/ui/avatar/avatar.component.html deleted file mode 100644 index d57806aa..00000000 --- a/webapp/src/app/ui/avatar/avatar.component.html +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/webapp/src/app/ui/avatar/avatar.component.ts b/webapp/src/app/ui/avatar/avatar.component.ts index d2eb5a09..39552d97 100644 --- a/webapp/src/app/ui/avatar/avatar.component.ts +++ b/webapp/src/app/ui/avatar/avatar.component.ts @@ -1,51 +1,31 @@ -import { Component, computed, input, signal } from '@angular/core'; +import { Component, computed, inject, InjectionToken, 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' - } -}); +const AvatarToken = new InjectionToken('AvatarToken'); -export { args, argTypes }; +export function injectAvatar(): AvatarComponent { + return inject(AvatarToken); +} -interface AvatarVariants extends VariantProps {} +type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; @Component({ selector: 'app-avatar', standalone: true, - imports: [NgOptimizedImage], - templateUrl: './avatar.component.html' + template: ``, + providers: [{ provide: AvatarToken, useExisting: AvatarComponent }], + host: { + '[class]': 'computedClass()' + } }) -export class AppAvatarComponent { - class = input(''); - size = input('default'); - - src = input.required(); - alt = input(''); - imageClass = input(''); - fallback = input('https://placehold.co/56'); +export class AvatarComponent { + readonly state = signal('idle'); - canShow = signal(true); + class = input(); + computedClass = computed(() => cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', this.class())); - 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())); + setState(state: ImageLoadingStatus): void { + this.state.set(state); + } } diff --git a/webapp/src/app/ui/avatar/avatar.stories.ts b/webapp/src/app/ui/avatar/avatar.stories.ts index 2aadf187..9bdfbc24 100644 --- a/webapp/src/app/ui/avatar/avatar.stories.ts +++ b/webapp/src/app/ui/avatar/avatar.stories.ts @@ -1,95 +1,34 @@ -import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular'; -import { AppAvatarComponent, args, argTypes } from './avatar.component'; +import { argsToTemplate, moduleMetadata, type Meta, type StoryObj } from '@storybook/angular'; +import { AvatarComponent } from './avatar.component'; +import { AvatarImageComponent } from './avatar-image.component'; +import { AvatarFallbackComponent } from './avatar-fallback.component'; -const meta: Meta = { +const meta: Meta = { title: 'UI/Avatar', - component: AppAvatarComponent, - tags: ['autodocs'], - args: { - ...args + component: AvatarComponent, + subcomponents: { + AvatarImageComponent, + AvatarFallbackComponent }, - argTypes: { - ...argTypes - } + decorators: [ + moduleMetadata({ + imports: [AvatarImageComponent, AvatarFallbackComponent] + }) + ], + tags: ['autodocs'] }; export default meta; -type Story = StoryObj; +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: `` + template: ` + + + CN + + ` }) };