~james/core

core/web/src/lib/components/ui/Avatar.svelte -rw-r--r-- 3.0 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
<script module lang="ts">
	import { tv, type VariantProps } from "tailwind-variants";

	export const avatarSizeClass = {
		mini: "size-3.25",
		small: "size-4.25",
		regular: "size-5.25",
		large: "size-6.5",
		full: "size-full"
	};

	export const avatar = tv({
		base: "relative flex shrink-0 items-center justify-center overflow-hidden rounded-full border border-border-default bg-background-canvas text-foreground-subtle",
		variants: {
			size: avatarSizeClass
		},
		defaultVariants: {
			size: "regular"
		}
	});

	export type AvatarSize = NonNullable<VariantProps<typeof avatar>["size"]>;

	export const avatarSizePx = {
		mini: 13,
		small: 17,
		regular: 21,
		large: 26,
		full: undefined
	} satisfies Record<AvatarSize, number | undefined>;

	export const avatarSizeNames = Object.keys(avatarSizePx) as AvatarSize[];
</script>

<script lang="ts">
	import UserRound from "$icon/user-round";
	import { avatarUrl, avatarWidthFor } from "#lib/avatar.ts";
	import { gradientCss } from "#lib/avatarGradient.ts";
	import type { Moderation } from "#lib/api/moderationLabels.ts";

	interface Props {
		did?: string;
		// null means the tangled profile has no avatar. the worker still tries bluesky
		avatarCid?: string | null;
		src?: string;
		handle?: string;
		size?: AvatarSize;
		pixels?: number;
		loading?: "lazy" | "eager";
		class?: string;
		moderation?: Moderation;
	}

	let {
		did,
		avatarCid,
		src,
		handle,
		size = "regular",
		pixels,
		loading = "lazy",
		class: className = "",
		moderation
	}: Props = $props();

	const px = $derived(pixels ?? avatarSizePx[size]);
	const source = $derived(
		moderation?.level === "hide"
			? undefined
			: (src ??
					(did
						? avatarUrl(
								did,
								px === undefined ? undefined : avatarWidthFor(px),
								undefined,
								avatarCid ?? undefined
							)
						: undefined))
	);

	// the gradient shows until the image loads and stays when there is none
	const placeholder = $derived(
		did && moderation?.level !== "hide" ? gradientCss(did) : undefined
	);

	// hidden with a style, not state, a page full of 404s would write state mid-render
	const hide = (event: Event) => {
		(event.currentTarget as HTMLImageElement).style.display = "none";
	};

	// a reused element has to recover from a previous failure
	const show = (event: Event) => {
		(event.currentTarget as HTMLImageElement).style.display = "";
	};
</script>

<span
	class={avatar({ size, class: className })}
	role={handle ? "img" : undefined}
	aria-label={handle ? `${handle}'s avatar` : undefined}
	aria-hidden={handle ? undefined : "true"}
>
	<span class="absolute inset-px rounded-full" style:background-image={placeholder}></span>
	{#if !placeholder}
		<UserRound class="size-1/2" />
	{/if}
	{#if source}
		<img
			src={source}
			alt=""
			width={px}
			height={px}
			{loading}
			decoding="async"
			class={[
				"absolute inset-0 size-full object-cover",
				moderation?.level === "warn" && "scale-125 blur-md"
			]}
			onerror={hide}
			onload={show}
		/>
	{/if}
</span>