Skip to content

Commit

Permalink
update avatar
Browse files Browse the repository at this point in the history
  • Loading branch information
FelixTJDietrich committed Aug 8, 2024
1 parent 7d9131a commit 89836a0
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 124 deletions.
24 changes: 24 additions & 0 deletions webapp/src/app/ui/avatar/avatar-config.ts
Original file line number Diff line number Diff line change
@@ -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<AvatarConfig>('AvatarConfigToken');

export function provideAvatarConfig(config: Partial<AvatarConfig>): Provider[] {
return [
{
provide: AvatarConfigToken,
useValue: { ...defaultAvatarConfig, ...config }
}
];
}

export function injectAvatarConfig(): AvatarConfig {
return inject(AvatarConfigToken, { optional: true }) ?? defaultAvatarConfig;
}
36 changes: 36 additions & 0 deletions webapp/src/app/ui/avatar/avatar-fallback.component.ts
Original file line number Diff line number Diff line change
@@ -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: `<ng-content />`,
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<ClassValue>();
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);
}
}
}
48 changes: 48 additions & 0 deletions webapp/src/app/ui/avatar/avatar-image.component.ts
Original file line number Diff line number Diff line change
@@ -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: `<img #imgEl [class]="computedClass()" [ngSrc]="src()" [alt]="alt()" (load)="onLoad()" (error)="onError()" fill />`,
host: {
'[class]': 'computedClass()',
'[style.display]': 'avatar.state() === "error" ? "none" : "inline"'
}
})
export class AvatarImageComponent implements OnInit, AfterViewInit {
readonly avatar = injectAvatar();
private readonly imgRef = viewChild<ElementRef<HTMLImageElement>>('imgEl');

class = input<ClassValue>();
src = input.required<string>();
alt = input<string>('');

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');
}
}
3 changes: 0 additions & 3 deletions webapp/src/app/ui/avatar/avatar.component.html

This file was deleted.

56 changes: 18 additions & 38 deletions webapp/src/app/ui/avatar/avatar.component.ts
Original file line number Diff line number Diff line change
@@ -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<AvatarComponent>('AvatarToken');

export { args, argTypes };
export function injectAvatar(): AvatarComponent {
return inject(AvatarToken);
}

interface AvatarVariants extends VariantProps<typeof avatarVariants> {}
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';

@Component({
selector: 'app-avatar',
standalone: true,
imports: [NgOptimizedImage],
templateUrl: './avatar.component.html'
template: `<ng-content />`,
providers: [{ provide: AvatarToken, useExisting: AvatarComponent }],
host: {
'[class]': 'computedClass()'
}
})
export class AppAvatarComponent {
class = input<ClassValue>('');
size = input<AvatarVariants['size']>('default');

src = input.required<string>();
alt = input<string>('');
imageClass = input<string>('');
fallback = input<string>('https://placehold.co/56');
export class AvatarComponent {
readonly state = signal<ImageLoadingStatus>('idle');

canShow = signal(true);
class = input<ClassValue>();
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);
}
}
105 changes: 22 additions & 83 deletions webapp/src/app/ui/avatar/avatar.stories.ts
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 1 in webapp/src/app/ui/avatar/avatar.stories.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

'argsToTemplate' is defined but never used
import { AvatarComponent } from './avatar.component';
import { AvatarImageComponent } from './avatar-image.component';
import { AvatarFallbackComponent } from './avatar-fallback.component';

const meta: Meta<AppAvatarComponent> = {
const meta: Meta<AvatarComponent> = {
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<AppAvatarComponent>;
type Story = StoryObj<AvatarComponent>;

export const Default: Story = {
args: {
src: 'https://i.pravatar.cc/40?img=1',
alt: 'avatar',
class: ''
},

render: (args) => ({
props: args,
template: `<app-avatar ${argsToTemplate(args)}></app-avatar>`
})
};

export const Small: Story = {
args: {
size: 'sm',
src: 'https://i.pravatar.cc/24?img=1'
},

render: (args) => ({
props: args,
template: `<app-avatar ${argsToTemplate(args)}></app-avatar>`
})
};

export const Medium: Story = {
args: {
size: 'default',
src: 'https://i.pravatar.cc/40?img=1'
},

render: (args) => ({
props: args,
template: `<app-avatar ${argsToTemplate(args)}></app-avatar>`
})
};

export const Large: Story = {
args: {
size: 'lg',
src: 'https://i.pravatar.cc/56?img=1',
alt: 'avatar',
class: ''
},

render: (args) => ({
props: args,
template: `<app-avatar ${argsToTemplate(args)}></app-avatar>`
})
};

export const WithRandomImage: Story = {
args: {
size: 'lg',
src: 'https://i.pravatar.cc/56',
alt: 'avatar'
},

render: (args) => ({
props: args,
template: `<app-avatar ${argsToTemplate(args)}></app-avatar>`
})
};

export const WithFallback: Story = {
args: {
size: 'default',
src: 'foobar.jpg',
fallback: 'https://placehold.co/40',
alt: 'fallback'
},

render: (args) => ({
props: args,
template: `<app-avatar ${argsToTemplate(args)}></app-avatar>`
template: `
<app-avatar>
<app-avatar-image src="https://github.com/shadcn.png" alt="@shadcn" />
<app-avatar-fallback>CN</app-avatar-fallback>
</app-avatar>
`
})
};

0 comments on commit 89836a0

Please sign in to comment.