Feature/refactoring user card (#274)

* Refactoring AuthorCard
* fix alphabet sort
This commit is contained in:
Ilya Y 2023-10-20 19:21:40 +03:00 committed by GitHub
parent 891d29ff6a
commit bfe1ef2e85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 529 additions and 695 deletions

View File

@ -240,7 +240,6 @@ img {
.shoutAuthorsList {
border-bottom: 1px solid #e8e8e8;
margin: 2em 0;
padding-bottom: 2em;
h4 {
color: #696969;

View File

@ -20,6 +20,7 @@ import { Author, Reaction, ReactionKind } from '../../graphql/types.gen'
import { router } from '../../stores/router'
import styles from './Comment.module.scss'
import { AuthorLink } from '../Author/AhtorLink'
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
@ -135,7 +136,6 @@ export const Comment = (props: Props) => {
<Userpic
name={comment().createdBy.name}
userpic={comment().createdBy.userpic}
isBig={false}
class={clsx({
[styles.compactUserpic]: props.compact
})}
@ -148,13 +148,7 @@ export const Comment = (props: Props) => {
>
<div class={styles.commentDetails}>
<div class={styles.commentAuthor}>
<AuthorCard
author={comment()?.createdBy as Author}
hideDescription={true}
hideFollow={true}
isComments={true}
hasLink={true}
/>
<AuthorLink author={comment()?.createdBy as Author} />
</div>
<Show when={props.isArticleAuthor}>

View File

@ -1,14 +1,16 @@
.commentDates {
color: #9fa1a7;
@include font-size(1.2rem);
color: var(--secondary-color);
align-items: center;
align-self: center;
display: flex;
flex: 1;
flex-wrap: wrap;
@include font-size(1.2rem);
font-size: 1.2rem;
justify-content: flex-start;
margin: 0 1em 0 0;
margin: 0 1rem;
height: 1.6rem;
.date {
font-weight: 500;

View File

@ -26,6 +26,7 @@ import { SolidSwiper } from '../_shared/SolidSwiper'
import styles from './Article.module.scss'
import { CardTopic } from '../Feed/CardTopic'
import { createPopper } from '@popperjs/core'
import { AuthorBadge } from '../Author/AuthorBadge'
type Props = {
article: Shout
@ -437,9 +438,9 @@ export const FullArticle = (props: Props) => {
<h4>{t('Authors')}</h4>
</Show>
<For each={props.article.authors}>
{(a) => (
{(author) => (
<div class="col-xl-12">
<AuthorCard author={a} hasLink={true} liteButtons={true} />
<AuthorBadge iconButtons={true} showMessageButton={true} author={author} />
</div>
)}
</For>

View File

@ -0,0 +1,49 @@
.AuthorLink {
.link {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 1rem;
justify-content: center;
padding: 0;
border-bottom: none;
&:hover {
background: unset !important;
border-bottom: none;
color: var(--default-color) !important;
}
.name {
font-weight: 500;
line-height: 1;
margin-bottom: -2px;
&:hover {
color: var(--default-color-invert);
background: var(--background-color-invert);
}
}
}
// adjust size
&.XS {
.link {
gap: 0.5rem;
}
.name {
font-size: 1.2rem;
margin: 0;
}
}
&.M {
.link {
gap: 1rem;
}
.name {
font-size: 1.4rem;
}
}
}

View File

@ -0,0 +1,21 @@
import { clsx } from 'clsx'
import styles from './AhtorLink.module.scss'
import { Author } from '../../../graphql/types.gen'
import { Userpic } from '../Userpic'
type Props = {
author: Author
size?: 'XS' | 'M' | 'L'
class?: string
}
export const AuthorLink = (props: Props) => {
return (
<div class={clsx(styles.AuthorLink, props.class, styles[props.size ?? 'M'])}>
<a class={styles.link} href={`/author/${props.author.slug}`}>
<Userpic size={props.size ?? 'M'} name={props.author.name} userpic={props.author.userpic} />
<div class={styles.name}>{props.author.name}</div>
</a>
</div>
)
}

View File

@ -0,0 +1 @@
export { AuthorLink } from './AuthorLink'

View File

@ -3,6 +3,7 @@
display: flex;
flex-flow: row nowrap;
margin-bottom: 2rem;
gap: 1rem;
@include media-breakpoint-down(sm) {
flex-wrap: wrap;
@ -33,6 +34,11 @@
.name {
color: var(--default-color);
font-weight: 500;
& span:hover {
color: var(--default-color-invert);
background: var(--background-color-invert);
}
}
.bio {
@ -42,7 +48,10 @@
.actions {
flex: 0 20%;
display: flex;
flex-direction: row;
margin-left: 5.2rem;
gap: 1rem;
@include media-breakpoint-up(sm) {
margin-left: 2rem;
@ -56,9 +65,18 @@
}
}
.subscribeButton {
.actionButton {
border-radius: 0.8rem !important;
margin-right: 0 !important;
width: 9em;
&.iconed {
padding: 6px !important;
min-width: 32px;
width: unset;
&:hover img {
filter: invert(1);
}
}
}
}

View File

@ -8,10 +8,15 @@ import { Button } from '../../_shared/Button'
import { useSession } from '../../../context/session'
import { follow, unfollow } from '../../../stores/zine/common'
import { CheckButton } from '../../_shared/CheckButton'
import { openPage } from '@nanostores/router'
import { router, useRouter } from '../../../stores/router'
import { Icon } from '../../_shared/Icon'
type Props = {
author: Author
minimizeSubscribeButton?: boolean
showMessageButton?: boolean
iconButtons?: boolean
}
export const AuthorBadge = (props: Props) => {
const [isSubscribing, setIsSubscribing] = createSignal(false)
@ -20,7 +25,7 @@ export const AuthorBadge = (props: Props) => {
subscriptions,
actions: { loadSubscriptions, requireAuthentication }
} = useSession()
const { changeSearchParam } = useRouter()
const { t, formatDate } = useLocalize()
const subscribed = createMemo(() =>
subscriptions().authors.some((author) => author.slug === props.author.slug)
@ -42,17 +47,34 @@ export const AuthorBadge = (props: Props) => {
}, 'subscribe')
}
const initChat = () => {
requireAuthentication(() => {
openPage(router, `inbox`)
changeSearchParam({
initChat: props.author.id.toString()
})
}, 'discussions')
}
const subscribeValue = createMemo(() => {
if (props.iconButtons) {
return <Icon name="author-subscribe" />
}
return isSubscribing() ? t('...subscribing') : t('Subscribe')
})
return (
<div class={clsx(styles.AuthorBadge)}>
<Userpic
hasLink={true}
isMedium={true}
size={'M'}
name={props.author.name}
userpic={props.author.userpic}
slug={props.author.slug}
/>
<a href={`/author/${props.author.slug}`} class={styles.info}>
<div class={styles.name}>{props.author.name}</div>
<div class={styles.name}>
<span>{props.author.name}</span>
</div>
<Switch
fallback={
<div class={styles.bio}>
@ -86,23 +108,32 @@ export const AuthorBadge = (props: Props) => {
when={subscribed()}
fallback={
<Button
variant="primary"
variant={props.iconButtons ? 'secondary' : 'bordered'}
size="S"
value={isSubscribing() ? t('...subscribing') : t('Subscribe')}
value={subscribeValue()}
onClick={() => handleSubscribe(true)}
class={styles.subscribeButton}
class={clsx(styles.actionButton, { [styles.iconed]: props.iconButtons })}
/>
}
>
<Button
variant="bordered"
variant={props.iconButtons ? 'secondary' : 'bordered'}
size="S"
value={t('Following')}
value={props.iconButtons ? <Icon name="author-unsubscribe" /> : t('Following')}
onClick={() => handleSubscribe(false)}
class={styles.subscribeButton}
class={clsx(styles.actionButton, { [styles.iconed]: props.iconButtons })}
/>
</Show>
</Show>
<Show when={props.showMessageButton}>
<Button
variant={props.iconButtons ? 'secondary' : 'bordered'}
size="S"
value={props.iconButtons ? <Icon name="inbox-white" /> : t('Message')}
onClick={initChat}
class={clsx(styles.actionButton, { [styles.iconed]: props.iconButtons })}
/>
</Show>
</div>
</Show>
</div>

View File

@ -1,6 +1,6 @@
.author {
display: flex;
align-items: center;
align-items: flex-start;
flex-flow: row nowrap;
margin-bottom: 1.6rem;
@ -8,10 +8,47 @@
margin-bottom: 0;
}
@include media-breakpoint-down(md) {
justify-content: center;
}
@include media-breakpoint-up(md) {
margin-bottom: 2.4rem;
}
.authorName {
@include font-size(4rem);
font-weight: 700;
margin-bottom: 0.2em;
}
.authorAbout {
color: #696969;
@include font-size(2rem);
font-weight: 500;
margin-top: 1.5rem;
}
.authorActions {
margin: 2rem -0.8rem 0 0;
padding-left: 0;
display: flex;
flex-direction: row;
gap: 1rem;
@include media-breakpoint-down(md) {
justify-content: center;
}
}
.authorDetails {
display: block;
@include media-breakpoint-down(md) {
flex: 1 100%;
text-align: center;
}
}
.listWrapper & {
align-items: flex-start;
margin-bottom: 2rem;
@ -39,7 +76,7 @@
}
.authorDetails {
flex: 1;
flex: 0 0 auto;
@include media-breakpoint-up(sm) {
align-items: center;
@ -57,10 +94,6 @@
flex-wrap: nowrap;
}
}
&.authorDetailsShrinked {
flex: 0 0 auto;
}
}
.authorDetailsWrapper {
@ -84,29 +117,9 @@
}
}
.authorNameContainer {
line-height: 1.1;
}
.authorName {
border: none !important;
font-size: 1.6rem;
font-weight: 500;
margin-bottom: 0.8rem;
.listWrapper & {
display: block;
&:before {
content: '';
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
z-index: 2;
}
}
@include font-size(4rem);
line-height: 1.1;
}
.authorAbout {
@ -117,42 +130,6 @@
word-break: break-word;
}
.authorSubscribe {
align-items: center;
@include media-breakpoint-down(md) {
flex-wrap: wrap;
}
.button {
padding-left: 2rem;
padding-right: 2rem;
margin-right: 0.5em;
&:first-of-type {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
&:hover {
.buttonUnfollowLabel {
display: block;
}
.buttonSubscribedLabel {
display: none;
}
}
.buttonUnfollowLabel {
display: none;
}
}
}
.authorSubscribeSocialLabel {
display: none;
}
@ -426,208 +403,6 @@
}
}
.shareControl {
display: inline-block;
}
.buttonSubscribe {
align-items: center;
aspect-ratio: 1/1;
border-radius: 100%;
display: inline-flex;
float: right;
img {
display: block;
}
}
.buttonLabel {
display: none;
}
.buttonLabelVisible {
display: block;
}
.buttonWrite {
background: #ccc;
color: #000;
display: inline-flex;
font-weight: 500;
transition:
background-color 0.3s,
color 0.3s;
&:hover {
background: #000;
color: #fff;
img {
filter: invert(1);
}
}
.icon {
display: inline-block;
margin-right: 0.5em;
}
img {
height: 15px;
transition: filter 0.3s;
}
}
.authorPage {
align-items: center;
@include media-breakpoint-down(md) {
justify-content: center;
}
.authorName {
@include font-size(4rem);
font-weight: 700;
margin-bottom: 0.2em;
}
.authorAbout {
color: #696969;
@include font-size(2rem);
font-weight: 500;
margin-top: 1.5rem;
}
.authorSubscribe {
margin: 2rem -0.8rem 0 0;
padding-left: 0;
@include media-breakpoint-down(md) {
justify-content: center;
}
}
.authorDetails {
display: block;
@include media-breakpoint-down(md) {
flex: 1 100%;
text-align: center;
}
}
.buttonLabel {
display: block;
}
.buttonSubscribe {
aspect-ratio: auto;
background-color: #000;
border-color: #000;
border-radius: 0.8rem;
color: #fff;
float: none;
padding-bottom: 0.6rem;
padding-top: 0.6rem;
width: 10em;
.icon {
margin-right: 0.5em;
img {
filter: invert(1);
}
}
&:hover {
background: #fff;
color: #000;
.icon img {
filter: invert(0);
}
}
}
.buttonSubscribe img {
vertical-align: text-top;
}
.button {
min-height: 4rem;
margin: 0 0.8rem 0 0;
vertical-align: middle;
@include media-breakpoint-down(sm) {
margin-bottom: 0.5em;
}
}
}
.authorsListItem {
margin-bottom: 1em !important;
.authorName {
@include font-size(2.2rem);
font-weight: bold;
}
.authorSubscribe {
align-items: baseline;
@include media-breakpoint-down(sm) {
padding: 1rem 0 0;
}
}
.buttonLabel {
display: block;
}
}
.nowrapView {
flex-wrap: nowrap;
align-items: center;
margin: 0;
}
.authorComments {
.authorName {
@include font-size(1.2rem);
line-height: 1.2;
margin-bottom: 0;
}
.circlewrap {
margin-top: -0.4em;
}
}
.isSubscribing {
opacity: 0.5;
}
.feedMode {
align-items: center;
margin-bottom: 0.4rem;
.authorName,
.authorAbout {
@include font-size(1.2rem);
margin-bottom: 0;
}
.circlewrap {
height: 1.6rem;
margin-right: 0.4rem;
min-width: 1.6rem;
width: 1.6rem;
}
}
.subscribersContainer {
display: flex;
flex-wrap: wrap;
@ -648,33 +423,33 @@
vertical-align: top;
border-bottom: unset !important;
&:last-child {
margin-right: 0;
}
.subscribersItem {
position: relative;
.userpic {
background: var(--background-color);
box-shadow: 0 0 0 2px var(--background-color);
height: 1.8rem;
min-width: 1.8rem;
max-width: 1.8rem;
vertical-align: top;
width: 1.8rem;
&:not(:first-child) {
margin-left: -1.8rem;
&:nth-child(1) {
z-index: 2;
}
> * {
line-height: 1.8rem;
min-width: auto;
&:nth-child(2) {
z-index: 1;
}
&:not(:last-child) {
margin-right: -4px;
box-shadow: 0 0 0 1px var(--background-color);
}
}
}
.subscribersCounter {
font-weight: 500;
margin-left: -0.6rem;
.subscribersCounter {
font-weight: 500;
margin-left: 1rem;
}
&:hover {
background: none !important;
.subscribersCounter {
background: var(--background-color-invert);
}
}
}
.listWrapper {

View File

@ -1,6 +1,5 @@
import type { Author } from '../../../graphql/types.gen'
import { Userpic } from '../Userpic'
import { Icon } from '../../_shared/Icon'
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'
import { translit } from '../../../utils/ru2en'
import { follow, unfollow } from '../../../stores/zine/common'
@ -11,7 +10,6 @@ import { FollowingEntity, Topic } from '../../../graphql/types.gen'
import { router, useRouter } from '../../../stores/router'
import { openPage, redirectPage } from '@nanostores/router'
import { useLocalize } from '../../../context/localize'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { Modal } from '../../Nav/Modal'
import { SubscriptionFilter } from '../../../pages/types'
import { isAuthor } from '../../../utils/isAuthor'
@ -22,28 +20,9 @@ import { getShareUrl, SharePopup } from '../../Article/SharePopup'
import styles from './AuthorCard.module.scss'
type Props = {
caption?: string
hideWriteButton?: boolean
hideDescription?: boolean
hideFollow?: boolean
hasLink?: boolean
subscribed?: boolean
author: Author
isAuthorPage?: boolean
noSocialButtons?: boolean
isAuthorsList?: boolean
truncateBio?: boolean
liteButtons?: boolean
isTextButton?: boolean
isComments?: boolean
isFeedMode?: boolean
isNowrap?: boolean
class?: string
followers?: Author[]
following?: Array<Author | Topic>
showPublicationsCounter?: boolean
hideBio?: boolean
isCurrentUser?: boolean
}
export const AuthorCard = (props: Props) => {
@ -58,7 +37,6 @@ export const AuthorCard = (props: Props) => {
const [isSubscribing, setIsSubscribing] = createSignal(false)
const [following, setFollowing] = createSignal<Array<Author | Topic>>(props.following)
const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all')
const [userpicUrl, setUserpicUrl] = createSignal<string>()
const subscribed = createMemo<boolean>(() =>
subscriptions().authors.some((author) => author.slug === props.author.slug)
@ -75,7 +53,7 @@ export const AuthorCard = (props: Props) => {
setIsSubscribing(false)
}
const canFollow = createMemo(() => !props.hideFollow && session()?.user?.slug !== props.author.slug)
const isProfileOwner = createMemo(() => session()?.user?.slug === props.author.slug)
const name = createMemo(() => {
if (lang() !== 'ru') {
@ -102,7 +80,7 @@ export const AuthorCard = (props: Props) => {
const handleSubscribe = () => {
requireAuthentication(() => {
subscribe(true)
subscribe(!subscribed())
}, 'subscribe')
}
@ -118,89 +96,28 @@ export const AuthorCard = (props: Props) => {
}
})
if (props.isAuthorPage && props.author.userpic?.includes('assets.discours.io')) {
setUserpicUrl(props.author.userpic.replace('100x', '500x500'))
const followButtonText = () => {
if (isSubscribing()) {
return t('...subscribing')
}
return t(subscribed() ? 'Unfollow' : 'Follow')
}
return (
<div
class={clsx(styles.author, props.class)}
classList={{
['row']: props.isAuthorPage,
[styles.authorPage]: props.isAuthorPage,
[styles.authorComments]: props.isComments,
[styles.authorsListItem]: props.isAuthorsList,
[styles.feedMode]: props.isFeedMode,
[styles.nowrapView]: props.isNowrap
}}
>
<Show
when={props.isAuthorPage}
fallback={
<Userpic
name={props.author.name}
userpic={props.author.userpic}
hasLink={props.hasLink}
isBig={props.isAuthorPage}
isAuthorsList={props.isAuthorsList}
isFeedMode={props.isFeedMode}
slug={props.author.slug}
class={styles.circlewrap}
/>
}
>
<div class="col-md-5">
<Userpic
name={props.author.name}
userpic={userpicUrl()}
hasLink={props.hasLink}
isBig={props.isAuthorPage}
isAuthorsList={props.isAuthorsList}
isFeedMode={props.isFeedMode}
slug={props.author.slug}
class={styles.circlewrap}
/>
</div>
</Show>
<div
class={styles.authorDetails}
classList={{
'col-md-15 col-xl-13': props.isAuthorPage,
[styles.authorDetailsShrinked]: props.isAuthorPage
}}
>
<div class={clsx(styles.author, 'row')}>
<div class="col-md-5">
<Userpic
size={'XL'}
name={props.author.name}
userpic={props.author.userpic}
slug={props.author.slug}
class={styles.circlewrap}
/>
</div>
<div class={clsx('col-md-15 col-xl-13', styles.authorDetails)}>
<div class={styles.authorDetailsWrapper}>
<div class={styles.authorNameContainer}>
<ConditionalWrapper
condition={props.hasLink}
wrapper={(children) => (
<a class={styles.authorName} href={`/author/${props.author.slug}`}>
{children}
</a>
)}
>
<span class={clsx({ [styles.authorName]: !props.hasLink })}>{name()}</span>
</ConditionalWrapper>
</div>
<Show
when={props.author.bio && !props.hideBio}
fallback={
props.showPublicationsCounter ? (
<div class={styles.authorAbout}>
{t('PublicationsWithCount', { count: props.author.stat?.shouts ?? 0 })}
</div>
) : (
''
)
}
>
<div
class={styles.authorAbout}
classList={{ 'text-truncate': props.truncateBio }}
innerHTML={props.author.bio}
/>
</Show>
<div class={styles.authorName}>{name()}</div>
<div class={styles.authorAbout} innerHTML={props.author.bio} />
<Show
when={
(props.followers && props.followers.length > 0) ||
@ -211,10 +128,17 @@ export const AuthorCard = (props: Props) => {
<Show when={props.followers && props.followers.length > 0}>
<a href="?modal=followers" class={styles.subscribers}>
<For each={props.followers.slice(0, 3)}>
{(f) => <Userpic name={f.name} userpic={f.userpic} class={styles.userpic} />}
{(f) => (
<Userpic
size={'XS'}
name={f.name}
userpic={f.userpic}
class={styles.subscribersItem}
/>
)}
</For>
<div class={styles.subscribersCounter}>
{t('SubscriberWithCount', { count: props.followers.length })}
{t('SubscriberWithCount', { count: props.followers.length ?? 0 })}
</div>
</a>
</Show>
@ -224,9 +148,23 @@ export const AuthorCard = (props: Props) => {
<For each={props.following.slice(0, 3)}>
{(f) => {
if ('name' in f) {
return <Userpic name={f.name} userpic={f.userpic} class={styles.userpic} />
return (
<Userpic
size={'XS'}
name={f.name}
userpic={f.userpic}
class={styles.subscribersItem}
/>
)
} else if ('title' in f) {
return <Userpic name={f.title} userpic={f.pic} class={styles.userpic} />
return (
<Userpic
size={'XS'}
name={f.title}
userpic={f.pic}
class={styles.subscribersItem}
/>
)
}
return null
}}
@ -259,102 +197,23 @@ export const AuthorCard = (props: Props) => {
</For>
</div>
</Show>
<Show when={canFollow()}>
<div class={styles.authorSubscribe}>
<Show
when={subscribed()}
fallback={
<button
onClick={handleSubscribe}
class={clsx('button', styles.button)}
classList={{
[styles.buttonSubscribe]: !props.isAuthorsList && !props.isTextButton,
'button--subscribe': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList || props.isTextButton,
[styles.buttonWrite]: props.isAuthorsList || props.isTextButton,
[styles.isSubscribing]: isSubscribing()
}}
disabled={isSubscribing()}
>
<Show when={!props.isAuthorsList && !props.isTextButton && !props.isAuthorPage}>
<Icon name="author-subscribe" class={styles.icon} />
</Show>
<Show when={props.isTextButton || props.isAuthorPage}>
<span class={clsx(styles.buttonLabel, styles.buttonLabelVisible)}>
{t('Follow')}
</span>
</Show>
</button>
}
>
<button
onClick={() => subscribe(false)}
class={clsx('button', styles.button)}
classList={{
[styles.buttonSubscribe]: !props.isAuthorsList && !props.isTextButton,
'button--subscribe': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList || props.isTextButton,
[styles.buttonWrite]: props.isAuthorsList || props.isTextButton,
[styles.isSubscribing]: isSubscribing()
}}
disabled={isSubscribing()}
>
<Show when={!props.isAuthorsList && !props.isTextButton && !props.isAuthorPage}>
<Icon name="author-unsubscribe" class={styles.icon} />
</Show>
<Show when={props.isTextButton || props.isAuthorPage}>
<span
class={clsx(
styles.buttonLabel,
styles.buttonLabelVisible,
styles.buttonUnfollowLabel
)}
>
{t('Unfollow')}
</span>
<span
class={clsx(
styles.buttonLabel,
styles.buttonLabelVisible,
styles.buttonSubscribedLabel
)}
>
{t('Following')}
</span>
</Show>
</button>
</Show>
<Show when={!props.hideWriteButton}>
<button
class={styles.button}
classList={{
'button--light': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList,
[styles.buttonWrite]: props.liteButtons && props.isAuthorsList
}}
onClick={initChat}
>
<Show when={!props.isTextButton && !props.isAuthorPage}>
<Icon name="comment" class={styles.icon} />
</Show>
<Show when={!props.liteButtons || props.isTextButton}>{t('Message')}</Show>
</button>
</Show>
</div>
</Show>
<Show when={props.isCurrentUser}>
<div class={styles.authorSubscribe}>
<Show
when={isProfileOwner()}
fallback={
<div class={styles.authorActions}>
<Button onClick={handleSubscribe} value={followButtonText()} />
<Button variant={'secondary'} value={t('Message')} onClick={initChat} />
</div>
}
>
<div class={styles.authorActions}>
<Button
variant="secondary"
onClick={() => redirectPage(router, 'profileSettings')}
value={t('Edit profile')}
class={styles.button}
/>
<SharePopup
containerCssClass={styles.shareControl}
title={props.author.name}
description={props.author.bio}
imageUrl={props.author.userpic}

View File

@ -1,14 +1,9 @@
.Userpic {
align-items: baseline;
background: #f7f7f8;
background: #f7f7f7;
border-radius: 100%;
display: flex;
justify-content: center;
margin-right: 1.2rem;
min-width: 32px;
max-width: 32px;
height: 32px;
width: 32px;
align-items: center;
overflow: hidden;
position: relative;
@ -20,17 +15,17 @@
}
.letters {
background-color: white;
border-radius: 50%;
border: 1.5px solid black;
color: #000;
font-size: small;
text-align: center;
text-transform: uppercase;
line-height: 32px;
width: 100%;
display: flex;
height: 100%;
min-width: 32px;
width: 100%;
border-radius: 100%;
padding-top: 2px; // line-height hack
justify-content: center;
align-items: center;
color: var(--default-color);
text-transform: uppercase;
background: var(--background-color);
box-shadow: 0 0 0 1px var(--background-color-invert) inset;
}
.anonymous {
@ -55,18 +50,39 @@
}
}
&.medium {
width: 40px;
height: 40px;
min-width: 40px;
max-width: 40px;
&.XS {
width: 20px;
height: 20px;
min-width: 20px;
overflow: hidden;
.letters {
line-height: 40px;
font-size: 0.8rem;
}
}
&.big {
&.S {
width: 28px;
height: 28px;
min-width: 28px;
overflow: hidden;
.letters {
font-size: 1rem;
}
}
&.M {
height: 32px;
width: 32px;
min-width: 32px;
.letters {
font-size: 1.2rem;
}
}
&.XL {
aspect-ratio: 1/1;
margin: 0 auto 1rem;
max-width: 168px;
@ -101,12 +117,3 @@
line-height: 6.4rem;
}
}
.feedMode {
.letters {
font-size: 0.8rem;
line-height: 14px;
min-width: 16px;
max-width: 16px;
}
}

View File

@ -1,4 +1,4 @@
import { Show } from 'solid-js'
import { createSignal, Show } from 'solid-js'
import styles from './Userpic.module.scss'
import { clsx } from 'clsx'
import { imageProxy } from '../../../utils/imageProxy'
@ -12,27 +12,47 @@ type Props = {
slug?: string
onClick?: () => void
loading?: boolean
isBig?: boolean
isMedium?: boolean
hasLink?: boolean
isAuthorsList?: boolean
isFeedMode?: boolean
size?: 'XS' | 'S' | 'M' | 'L' | 'XL' // 20 | 28 | 32 | 40 | 168
}
export const Userpic = (props: Props) => {
const [userpicUrl, setUserpicUrl] = createSignal<string>()
const letters = () => {
if (!props.name) return
const names = props.name ? props.name.split(' ') : []
return names[0][0] + (names.length > 1 ? names[1][0] : '')
}
const comutedAvatarSize = () => {
switch (props.size) {
case 'XS': {
return '40x40'
}
case 'S': {
return '56x56'
}
case 'L': {
return '80x80'
}
case 'XL': {
return '336x336'
}
default: {
return '64x64'
}
}
}
setUserpicUrl(
props.userpic && props.userpic.includes('100x')
? props.userpic.replace('100x', comutedAvatarSize())
: props.userpic
)
return (
<div
class={clsx(styles.Userpic, props.class, {
[styles.big]: props.isBig,
[styles.medium]: props.isMedium,
[styles.authorsList]: props.isAuthorsList,
[styles.feedMode]: props.isFeedMode,
class={clsx(styles.Userpic, props.class, styles[props.size ?? 'M'], {
['cursorPointer']: props.onClick
})}
onClick={props.onClick}
@ -47,7 +67,7 @@ export const Userpic = (props: Props) => {
fallback={
<img
class={clsx({ [styles.anonymous]: !props.userpic })}
src={imageProxy(props.userpic) || '/icons/user-default.svg'}
src={imageProxy(userpicUrl()) || '/icons/user-default.svg'}
alt={props.name || ''}
loading="lazy"
/>

View File

@ -68,6 +68,8 @@
a:link {
border: none;
position: relative;
overflow: hidden;
&::before {
content: '';

View File

@ -1,22 +1,22 @@
import { createMemo, createSignal, For, Show } from 'solid-js'
import type { Shout } from '../../graphql/types.gen'
import { Icon } from '../_shared/Icon'
import type { Shout } from '../../../graphql/types.gen'
import { capitalize } from '../../../utils/capitalize'
import { Icon } from '../../_shared/Icon'
import styles from './ArticleCard.module.scss'
import { clsx } from 'clsx'
import { CardTopic } from './CardTopic'
import { ShoutRatingControl } from '../Article/ShoutRatingControl'
import { getShareUrl, SharePopup } from '../Article/SharePopup'
import stylesHeader from '../Nav/Header/Header.module.scss'
import { getDescription } from '../../utils/meta'
import { FeedArticlePopup } from './FeedArticlePopup'
import { useLocalize } from '../../context/localize'
import { CardTopic } from '../CardTopic'
import { ShoutRatingControl } from '../../Article/ShoutRatingControl'
import { getShareUrl, SharePopup } from '../../Article/SharePopup'
import stylesHeader from '../../Nav/Header/Header.module.scss'
import { getDescription } from '../../../utils/meta'
import { FeedArticlePopup } from '../FeedArticlePopup'
import { useLocalize } from '../../../context/localize'
import { getPagePath, openPage } from '@nanostores/router'
import { router, useRouter } from '../../stores/router'
import { imageProxy } from '../../utils/imageProxy'
import { Popover } from '../_shared/Popover'
import { AuthorCard } from '../Author/AuthorCard'
import { useSession } from '../../context/session'
import { capitalize } from '../../utils/capitalize'
import { router, useRouter } from '../../../stores/router'
import { imageProxy } from '../../../utils/imageProxy'
import { Popover } from '../../_shared/Popover'
import { useSession } from '../../../context/session'
import { AuthorLink } from '../../Author/AhtorLink'
interface ArticleCardProps {
settings?: {
@ -180,17 +180,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
<div class={styles.shoutAuthor}>
<For each={props.article.authors}>
{(author) => {
return (
<AuthorCard
author={author}
hideWriteButton={true}
hideBio={true}
hideFollow={true}
truncateBio={true}
isFeedMode={true}
hasLink={!props.settings?.noAuthorLink}
/>
)
return <AuthorLink size={'XS'} author={author} />
}}
</For>
</div>

View File

@ -0,0 +1 @@
export { ArticleCard } from './ArticleCard'

View File

@ -201,6 +201,7 @@ export const HeaderAuth = (props: Props) => {
<button class={styles.button}>
<div classList={{ entered: page().path === `/${session().user?.slug}` }}>
<Userpic
size={'M'}
name={session().user.name}
userpic={session().user.userpic}
class={styles.userpic}

View File

@ -1,50 +0,0 @@
import { AuthorCard } from '../Author/AuthorCard'
import type { Author } from '../../graphql/types.gen'
import { translit } from '../../utils/ru2en'
import { hideModal } from '../../stores/ui'
import { createMemo, For } from 'solid-js'
import { useSession } from '../../context/session'
import { useLocalize } from '../../context/localize'
export const ProfileModal = () => {
const {
session,
actions: { signOut }
} = useSession()
const quit = () => {
signOut()
hideModal()
}
const { t, lang } = useLocalize()
const author = createMemo<Author>(() => {
const a: Author = {
id: null,
name: 'anonymous',
userpic: '',
slug: ''
}
if (session()?.user?.slug) {
const u = session().user
a.name = lang() === 'ru' ? u.name : translit(u.name)
a.slug = u.slug
a.userpic = u.userpic
}
return a
})
// TODO: ProfileModal markup and styles
return (
<div class="row view profile">
<h1>{session()?.user?.username}</h1>
<AuthorCard author={author()} />
<div class="profile-bio">{session()?.user?.bio || ''}</div>
<For each={session()?.user?.links || []}>{(l: string) => <a href={l}>{l}</a>}</For>
<span onClick={quit}>{t('Quit')}</span>
</div>
)
}

View File

@ -30,7 +30,8 @@
}
.userpic {
margin-right: 15px;
min-width: 40px;
margin-right: 1rem;
}
.timeContainer {

View File

@ -1,15 +1,15 @@
import { clsx } from 'clsx'
import type { Notification } from '../../../graphql/types.gen'
import type { Author, Notification } from '../../../graphql/types.gen'
import { createMemo, createSignal, onMount, Show } from 'solid-js'
import { NotificationType } from '../../../graphql/types.gen'
import { getPagePath, openPage } from '@nanostores/router'
import { router, useRouter } from '../../../stores/router'
import { useNotifications } from '../../../context/notifications'
import { Userpic } from '../../Author/Userpic'
import { useLocalize } from '../../../context/localize'
import type { ArticlePageSearchParams } from '../../Article/FullArticle'
import { TimeAgo } from '../../_shared/TimeAgo'
import styles from './NotificationView.module.scss'
import { GroupAvatar } from '../../_shared/GroupAvatar'
type Props = {
notification: Notification
@ -18,17 +18,19 @@ type Props = {
class?: string
}
export type NotificationUser = {
id: number
name: string
slug: string
userpic: string
}
type NotificationData = {
shout: {
slug: string
title: string
}
users: {
id: number
name: string
slug: string
userpic: string
}[]
users: NotificationUser[]
reactionIds: number[]
}
@ -160,7 +162,9 @@ export const NotificationView = (props: Props) => {
})}
onClick={handleClick}
>
<Userpic name={lastUser().name} userpic={lastUser().userpic} class={styles.userpic} />
<div class={styles.userpic}>
<GroupAvatar authors={data().users} />
</div>
<div>{content()}</div>
<div class={styles.timeContainer}>{formattedDateTime()}</div>
</div>

View File

@ -1,15 +1,13 @@
import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js'
import type { Author } from '../../graphql/types.gen'
import { setAuthorsSort, useAuthorsStore } from '../../stores/zine/authors'
import { useRouter } from '../../stores/router'
import { AuthorCard } from '../Author/AuthorCard'
import { clsx } from 'clsx'
import { useSession } from '../../context/session'
import { SearchField } from '../_shared/SearchField'
import { scrollHandler } from '../../utils/scroll'
import { useLocalize } from '../../context/localize'
import { dummyFilter } from '../../utils/dummyFilter'
import { AuthorBadge } from '../Author/AuthorBadge'
import styles from './AllAuthors.module.scss'
@ -35,8 +33,6 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
const [searchQuery, setSearchQuery] = createSignal('')
const { session, subscriptions } = useSession()
onMount(() => {
if (!searchParams().by) {
changeSearchParam({
@ -50,19 +46,28 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
})
const byLetter = createMemo<{ [letter: string]: Author[] }>(() => {
return sortedAuthors().reduce(
(acc, author) => {
let letter = author.name.trim().split(' ').pop().at(0).toUpperCase()
return sortedAuthors()
.slice(0, 1)
.reduce(
(acc, author) => {
let letter = ''
if (author && author.name) {
const nameParts = author.name.trim().split(' ')
const lastName = nameParts.pop()
if (lastName && lastName.length > 0) {
letter = lastName[0].toUpperCase()
}
}
if (/[^ËА-яё]/.test(letter) && lang() === 'ru') letter = '@'
if (/[^ËА-яё]/.test(letter) && lang() === 'ru') letter = '@'
if (!acc[letter]) acc[letter] = []
if (!acc[letter]) acc[letter] = []
acc[letter].push(author)
return acc
},
{} as { [letter: string]: Author[] }
)
acc[letter].push(author)
return acc
},
{} as { [letter: string]: Author[] }
)
})
const sortedKeys = createMemo<string[]>(() => {
@ -72,9 +77,6 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
return keys
})
const subscribed = (authorSlug: string) =>
subscriptions().authors.some((author) => author.slug === authorSlug)
const filteredAuthors = createMemo(() => {
return dummyFilter(sortedAuthors(), searchQuery(), lang())
})
@ -85,7 +87,6 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
<div class="col-lg-20 col-xl-18">
<h1>{t('Authors')}</h1>
<p>{t('Subscribe who you like to tune your personal feed')}</p>
<ul class={clsx(styles.viewSwitcher, 'view-switcher')}>
<li
classList={{
@ -173,15 +174,7 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
{(author) => (
<div class="row">
<div class="col-lg-20 col-xl-18">
<AuthorCard
author={author as Author}
hasLink={true}
subscribed={subscribed(author.slug)}
noSocialButtons={true}
isAuthorsList={true}
truncateBio={true}
isTextButton={true}
/>
<AuthorBadge author={author as Author} />
</div>
</div>
)}

View File

@ -128,13 +128,7 @@ export const AuthorView = (props: Props) => {
<Show when={author()} fallback={<Loading />}>
<>
<div class={styles.authorHeader}>
<AuthorCard
author={author()}
isAuthorPage={true}
followers={followers()}
following={following()}
isCurrentUser={author().slug === user()?.slug}
/>
<AuthorCard author={author()} followers={followers()} following={following()} />
</div>
<div class={clsx(styles.groupControls, 'row')}>
<div class="col-md-16">

View File

@ -156,6 +156,7 @@
.commentDetails {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: space-between;
}
@ -169,6 +170,9 @@
a {
border: none;
padding-bottom: 0.2em;
&:hover * {
background: var(--background-color-invert);
}
}
}

View File

@ -18,6 +18,8 @@ import stylesTopic from '../Feed/CardTopic.module.scss'
import stylesBeside from '../../components/Feed/Beside.module.scss'
import { CommentDate } from '../Article/CommentDate'
import { Loading } from '../_shared/Loading'
import { AuthorBadge } from '../Author/AuthorBadge'
import { AuthorLink } from '../Author/AhtorLink'
export const FEED_PAGE_SIZE = 20
@ -163,13 +165,7 @@ export const FeedView = (props: Props) => {
<For each={topAuthors().slice(0, 5)}>
{(author) => (
<li>
<AuthorCard
author={author}
hideWriteButton={true}
hasLink={true}
truncateBio={true}
isTextButton={true}
/>
<AuthorBadge author={author} />
</li>
)}
</For>
@ -207,13 +203,7 @@ export const FeedView = (props: Props) => {
/>
</div>
<div class={styles.commentDetails}>
<AuthorCard
author={comment.createdBy as Author}
isFeedMode={true}
hideWriteButton={true}
hideFollow={true}
hasLink={true}
/>
<AuthorLink author={comment.createdBy as Author} size={'XS'} />
<CommentDate comment={comment} isShort={true} isLastInRow={true} />
</div>
<div class={clsx('text-truncate', styles.commentArticleTitle)}>

View File

@ -0,0 +1,88 @@
.GroupAvatar {
display: flex;
gap: 1rem;
align-items: center;
width: 40px;
height: 40px;
position: relative;
.item {
position: absolute;
}
&.two {
.item {
border: 1px solid var(--background-color);
&:nth-child(1) {
left: 0;
top: 0;
}
&:nth-child(2) {
right: 0;
bottom: 0;
z-index: 1;
}
}
}
&.three {
.item {
&:nth-child(1) {
left: 14px;
top: 0;
}
&:nth-child(2) {
left: 0;
top: 17px;
}
&:nth-child(3) {
right: 0;
top: 21px;
}
}
}
&.four {
.item {
width: 19px;
height: 19px;
min-width: 19px;
&:nth-child(1) {
left: 0;
top: 0;
}
&:nth-child(2) {
right: 0;
top: 0;
}
&:nth-child(3) {
left: 0;
bottom: 0;
}
&:nth-child(4) {
right: 0;
bottom: 0;
}
}
}
.moreUsers {
@include font-size(0.75rem);
font-weight: 700;
position: absolute;
width: 19px;
height: 19px;
overflow: hidden;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--black-100);
border-radius: 100%;
}
}

View File

@ -0,0 +1,43 @@
import { clsx } from 'clsx'
import styles from './GroupAvatar.module.scss'
import { For } from 'solid-js'
import { Userpic } from '../../Author/Userpic'
import { NotificationUser } from '../../NotificationsPanel/NotificationView/NotificationView'
type Props = {
class?: string
authors: NotificationUser[]
}
export const GroupAvatar = (props: Props) => {
const displayedAvatars = props.authors.length > 4 ? props.authors.slice(0, 3) : props.authors.slice(0, 4)
const avatarSize = () => {
switch (props.authors.length) {
case 1: {
return 'L'
}
case 2: {
return 'S'
}
default: {
return 'XS'
}
}
}
return (
<div
class={clsx(styles.GroupAvatar, props.class, {
[styles.two]: props.authors.length === 2,
[styles.three]: props.authors.length === 3,
[styles.four]: props.authors.length >= 4
})}
>
<For each={displayedAvatars}>
{(user) => (
<Userpic size={avatarSize()} name={user.name} userpic={user.userpic} class={styles.item} />
)}
</For>
{props.authors.length > 4 && <div class={styles.moreUsers}>+{props.authors?.length - 3}</div>}
</div>
)
}

View File

@ -0,0 +1 @@
export { GroupAvatar } from './GroupAvatar'

View File

@ -22,12 +22,7 @@ export const VotersList = (props: Props) => {
{(reaction) => (
<li class={styles.item}>
<div class={styles.user}>
<Userpic
name={reaction.createdBy.name}
userpic={reaction.createdBy.userpic}
isBig={false}
isAuthorsList={false}
/>
<Userpic name={reaction.createdBy.name} userpic={reaction.createdBy.userpic} />
<a href={`/author/${reaction.createdBy.slug}`}>{reaction.createdBy.name || ''}</a>
</div>
{reaction.kind === ReactionKind.Like ? (

View File

@ -127,7 +127,7 @@ export const ProfileSettingsPage = () => {
<Userpic
name={form.name}
userpic={form.userpic}
isBig={true}
size={'XL'}
onClick={handleAvatarClick}
loading={isUserpicUpdating()}
/>