Skip to content

Commit 27bc35f

Browse files
authored
feat(blog-avatar): add blog-avatar component (#77)
1 parent dc8b81c commit 27bc35f

5 files changed

Lines changed: 417 additions & 0 deletions

File tree

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@
4949
"./antd": {
5050
"types": "./dist/antd/index.d.ts",
5151
"import": "./dist/antd/index.js"
52+
},
53+
"./blog-avatar": {
54+
"types": "./dist/blog-avatar/index.d.ts",
55+
"import": "./dist/blog-avatar/index.js"
5256
}
5357
},
5458
"repository": {

rslib.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export default defineConfig({
2525
entry: {
2626
'nav-icon': './src/nav-icon/index.tsx',
2727
benchmark: './src/benchmark/index.tsx',
28+
'blog-avatar': './src/blog-avatar/index.tsx',
2829
'tool-stack': './src/tool-stack/index.tsx',
2930
hero: './src/hero/index.tsx',
3031
'section-style': './src/section-style/index.tsx',

src/blog-avatar/index.module.scss

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
:global {
2+
html:not(.dark) {
3+
--rs-blog-avatar-group-ring: rgba(255, 255, 255, 0.92);
4+
--rs-blog-avatar-group-overflow-bg: var(--rp-c-bg-soft);
5+
}
6+
7+
html.dark {
8+
--rs-blog-avatar-group-ring: #111827;
9+
--rs-blog-avatar-group-overflow-bg: rgba(255, 255, 255, 0.08);
10+
}
11+
}
12+
13+
.root {
14+
display: inline-flex;
15+
align-items: center;
16+
gap: 10px;
17+
max-width: 100%;
18+
padding: 4px 0;
19+
}
20+
21+
.avatar {
22+
width: 40px;
23+
height: 40px;
24+
border-radius: 50%;
25+
object-fit: cover;
26+
flex-shrink: 0;
27+
pointer-events: none;
28+
box-shadow: 0 0 0 1px rgba(143, 161, 185, 0.18);
29+
}
30+
31+
.content {
32+
display: flex;
33+
flex-direction: column;
34+
min-width: 0;
35+
gap: 2px;
36+
}
37+
38+
.nameRow {
39+
display: flex;
40+
align-items: center;
41+
gap: 8px;
42+
min-width: 0;
43+
}
44+
45+
.name {
46+
color: var(--rp-c-text-1);
47+
font-size: 0.875rem;
48+
font-weight: 600;
49+
line-height: 1.25rem;
50+
white-space: nowrap;
51+
overflow: hidden;
52+
text-overflow: ellipsis;
53+
}
54+
55+
.title {
56+
color: var(--rp-c-text-2);
57+
font-size: 0.75rem;
58+
line-height: 1.35;
59+
white-space: nowrap;
60+
overflow: hidden;
61+
text-overflow: ellipsis;
62+
}
63+
64+
.links {
65+
display: flex;
66+
align-items: center;
67+
gap: 4px;
68+
color: var(--rp-c-text-2);
69+
opacity: 0.55;
70+
transition:
71+
opacity 0.2s ease,
72+
color 0.2s ease;
73+
}
74+
75+
.root:hover .links,
76+
.root:focus-within .links {
77+
opacity: 1;
78+
}
79+
80+
.link {
81+
display: inline-flex;
82+
align-items: center;
83+
justify-content: center;
84+
width: 20px;
85+
height: 20px;
86+
border-radius: 999px;
87+
color: inherit;
88+
transition:
89+
color 0.2s ease,
90+
background-color 0.2s ease;
91+
92+
&:hover,
93+
&:focus-visible {
94+
color: var(--rp-c-text-1);
95+
}
96+
97+
&:focus-visible {
98+
outline: 2px solid var(--rp-c-text-1);
99+
outline-offset: 2px;
100+
}
101+
102+
svg {
103+
width: 12px;
104+
height: 12px;
105+
}
106+
}
107+
108+
.group {
109+
display: flex;
110+
flex-wrap: wrap;
111+
gap: 10px 20px;
112+
max-width: 100%;
113+
}
114+
115+
.groupItem {
116+
max-width: 100%;
117+
}
118+
119+
.compactGroup {
120+
display: flex;
121+
align-items: center;
122+
gap: 10px;
123+
min-width: 0;
124+
max-width: 100%;
125+
}
126+
127+
.compactAvatars {
128+
display: flex;
129+
align-items: center;
130+
flex-shrink: 0;
131+
}
132+
133+
.compactAvatar,
134+
.compactOverflow {
135+
width: 28px;
136+
height: 28px;
137+
border-radius: 50%;
138+
box-shadow: 0 0 0 2px var(--rs-blog-avatar-group-ring);
139+
}
140+
141+
.compactAvatar {
142+
object-fit: cover;
143+
background-color: var(--rp-c-bg-soft);
144+
}
145+
146+
.compactAvatar + .compactAvatar,
147+
.compactOverflow {
148+
margin-left: -8px;
149+
}
150+
151+
.compactOverflow {
152+
display: inline-flex;
153+
align-items: center;
154+
justify-content: center;
155+
color: var(--rp-c-text-1);
156+
font-size: 0.75rem;
157+
font-weight: 600;
158+
line-height: 1;
159+
background-color: var(--rs-blog-avatar-group-overflow-bg);
160+
}
161+
162+
.compactNames {
163+
min-width: 0;
164+
color: var(--rp-c-text-2);
165+
font-size: 0.875rem;
166+
line-height: 1.35;
167+
white-space: nowrap;
168+
overflow: hidden;
169+
text-overflow: ellipsis;
170+
}
171+
172+
@media (max-width: 768px) {
173+
.group {
174+
gap: 10px 16px;
175+
}
176+
177+
.compactGroup {
178+
gap: 8px;
179+
}
180+
181+
.compactNames {
182+
font-size: 0.8125rem;
183+
}
184+
}

src/blog-avatar/index.tsx

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import type { ReactNode } from 'react';
2+
import styles from './index.module.scss';
3+
4+
const MAX_VISIBLE_COMPACT_AUTHORS = 3;
5+
6+
export type BlogAvatarLink = {
7+
href: string;
8+
label: string;
9+
icon: ReactNode;
10+
};
11+
12+
export type BlogAvatarAuthor = {
13+
name: string;
14+
avatar: string;
15+
title?: string;
16+
github?: string;
17+
x?: string;
18+
links?: BlogAvatarLink[];
19+
};
20+
21+
export type BlogAvatarProps = {
22+
author: BlogAvatarAuthor;
23+
className?: string;
24+
};
25+
26+
export type BlogAvatarGroupProps = {
27+
authors: BlogAvatarAuthor[];
28+
className?: string;
29+
compact?: boolean;
30+
};
31+
32+
const getClassName = (...classNames: Array<string | false | undefined>) => {
33+
return classNames.filter(Boolean).join(' ');
34+
};
35+
36+
const getAuthorKey = (author: BlogAvatarAuthor) => {
37+
return `${author.name}-${author.github ?? author.x ?? author.avatar}`;
38+
};
39+
40+
const GitHubIcon = () => (
41+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
42+
<path
43+
fill="currentColor"
44+
d="M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
45+
/>
46+
</svg>
47+
);
48+
49+
const XIcon = () => (
50+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
51+
<path
52+
fill="currentColor"
53+
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584l-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
54+
/>
55+
</svg>
56+
);
57+
58+
const getSocialLinks = ({
59+
github,
60+
links,
61+
name,
62+
x,
63+
}: BlogAvatarAuthor): BlogAvatarLink[] => {
64+
return [
65+
...(github
66+
? [
67+
{
68+
href: github,
69+
label: `${name}'s GitHub`,
70+
icon: <GitHubIcon />,
71+
},
72+
]
73+
: []),
74+
...(x
75+
? [
76+
{
77+
href: x,
78+
label: `${name}'s X profile`,
79+
icon: <XIcon />,
80+
},
81+
]
82+
: []),
83+
...(links ?? []),
84+
];
85+
};
86+
87+
export function BlogAvatar({ author, className }: BlogAvatarProps) {
88+
const { avatar, name, title } = author;
89+
const socialLinks = getSocialLinks(author);
90+
91+
return (
92+
<div className={getClassName(styles.root, className)}>
93+
<img
94+
src={avatar}
95+
alt={name}
96+
className={styles.avatar}
97+
loading="lazy"
98+
decoding="async"
99+
/>
100+
<div className={styles.content}>
101+
<div className={styles.nameRow}>
102+
<div className={styles.name}>{name}</div>
103+
{socialLinks.length > 0 ? (
104+
<div className={styles.links}>
105+
{socialLinks.map(link => (
106+
<a
107+
href={link.href}
108+
key={`${link.label}-${link.href}`}
109+
target="_blank"
110+
rel="noopener noreferrer"
111+
className={styles.link}
112+
aria-label={link.label}
113+
>
114+
{link.icon}
115+
</a>
116+
))}
117+
</div>
118+
) : null}
119+
</div>
120+
{title ? <div className={styles.title}>{title}</div> : null}
121+
</div>
122+
</div>
123+
);
124+
}
125+
126+
export function BlogAvatarGroup({
127+
authors,
128+
className,
129+
compact = false,
130+
}: BlogAvatarGroupProps) {
131+
if (authors.length === 0) {
132+
return null;
133+
}
134+
135+
if (!compact) {
136+
return (
137+
<div className={getClassName(styles.group, className)}>
138+
{authors.map(author => (
139+
<BlogAvatar
140+
author={author}
141+
className={styles.groupItem}
142+
key={getAuthorKey(author)}
143+
/>
144+
))}
145+
</div>
146+
);
147+
}
148+
149+
const visibleAuthors = authors.slice(0, MAX_VISIBLE_COMPACT_AUTHORS);
150+
const overflowCount = authors.length - visibleAuthors.length;
151+
const authorNames = authors.map(author => author.name).join(', ');
152+
153+
return (
154+
<div
155+
className={getClassName(styles.compactGroup, className)}
156+
title={authorNames}
157+
>
158+
<div className={styles.compactAvatars} aria-hidden="true">
159+
{visibleAuthors.map(author => (
160+
<img
161+
src={author.avatar}
162+
alt=""
163+
className={styles.compactAvatar}
164+
loading="lazy"
165+
decoding="async"
166+
key={getAuthorKey(author)}
167+
/>
168+
))}
169+
{overflowCount > 0 ? (
170+
<span className={styles.compactOverflow}>+{overflowCount}</span>
171+
) : null}
172+
</div>
173+
<div className={styles.compactNames}>{authorNames}</div>
174+
</div>
175+
);
176+
}

0 commit comments

Comments
 (0)