webapp/src/components/Feed/ArticleCard/ArticleCard.tsx

410 lines
16 KiB
TypeScript
Raw Normal View History

2024-06-24 17:50:27 +00:00
import { A, useNavigate, useSearchParams } from '@solidjs/router'
2022-10-19 14:26:49 +00:00
import { clsx } from 'clsx'
2024-06-24 17:50:27 +00:00
import { Accessor, For, Show, createMemo, createSignal } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
2024-06-24 17:50:27 +00:00
import type { Author, Maybe, Shout, Topic } from '../../../graphql/schema/core.gen'
import { capitalize } from '../../../utils/capitalize'
import { getDescription } from '../../../utils/meta'
import { CoverImage } from '../../Article/CoverImage'
2024-02-04 11:25:21 +00:00
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
import { ShoutRatingControl } from '../../Article/ShoutRatingControl'
2023-12-28 00:30:09 +00:00
import { AuthorLink } from '../../Author/AuthorLink'
2024-06-24 17:50:27 +00:00
import stylesHeader from '../../Nav/Header/Header.module.scss'
2024-02-04 11:25:21 +00:00
import { Icon } from '../../_shared/Icon'
import { Image } from '../../_shared/Image'
import { Popover } from '../../_shared/Popover'
import { CardTopic } from '../CardTopic'
import { FeedArticlePopup } from '../FeedArticlePopup'
2024-02-04 11:25:21 +00:00
import styles from './ArticleCard.module.scss'
2022-09-09 11:53:35 +00:00
export type ArticleCardProps = {
2024-02-17 18:57:02 +00:00
// TODO: refactor this, please
2022-09-09 11:53:35 +00:00
settings?: {
noicon?: boolean
noimage?: boolean
nosubtitle?: boolean
noauthor?: boolean
nodate?: boolean
isGroup?: boolean
photoBottom?: boolean
additionalClass?: string
isFeedMode?: boolean
2022-10-19 14:26:49 +00:00
isFloorImportant?: boolean
isWithCover?: boolean
isBigTitle?: boolean
isVertical?: boolean
isShort?: boolean
withBorder?: boolean
isCompact?: boolean
isSingle?: boolean
2022-11-16 21:08:04 +00:00
isBeside?: boolean
2023-07-09 18:34:59 +00:00
withViewed?: boolean
noAuthorLink?: boolean
2022-09-09 11:53:35 +00:00
}
2023-12-25 11:35:04 +00:00
withAspectRatio?: boolean
2024-06-24 17:50:27 +00:00
desktopCoverSize?: string // 'XS' | 'S' | 'M' | 'L'
2022-09-09 11:53:35 +00:00
article: Shout
2024-01-08 13:02:52 +00:00
onShare?: (article: Shout) => void
onInvite?: () => void
2022-09-09 11:53:35 +00:00
}
2024-06-24 17:50:27 +00:00
const desktopCoverImageWidths: Record<string, number> = {
XS: 300,
S: 400,
M: 600,
2024-06-26 08:22:05 +00:00
L: 800
}
const getTitleAndSubtitle = (
2024-06-26 08:22:05 +00:00
article: Shout
): {
title: string
subtitle: string
} => {
2024-06-28 07:47:38 +00:00
let title = article?.title || ''
let subtitle: string = article?.subtitle || ''
2022-09-09 11:53:35 +00:00
2022-09-22 09:37:49 +00:00
if (!subtitle) {
2024-03-04 15:06:40 +00:00
let titleParts = article.title?.split('. ') || []
2022-09-09 11:53:35 +00:00
2024-03-04 15:06:40 +00:00
if (titleParts?.length === 1) {
titleParts = article.title?.split(/{!|\?|:|;}\s/) || []
2022-09-22 09:37:49 +00:00
}
2022-09-09 11:53:35 +00:00
2024-03-04 15:06:40 +00:00
if (titleParts && titleParts.length > 1) {
const sep = article.title?.replace(titleParts[0], '').split(' ', 1)[0]
title = titleParts[0] + (sep === '.' || sep === ':' ? '' : sep)
subtitle = capitalize(article.title?.replace(titleParts[0] + sep, ''), true) || ''
2022-09-09 11:53:35 +00:00
}
}
2024-02-04 17:40:15 +00:00
// TODO: simple fast auto translated title/substitle
2022-09-22 09:37:49 +00:00
return { title, subtitle }
}
2024-02-04 17:40:15 +00:00
const getMainTopicTitle = (article: Shout, lng: string) => {
2024-05-06 23:44:06 +00:00
const mainTopicSlug = article?.main_topic || ''
2024-06-24 17:50:27 +00:00
const mainTopic = (article?.topics || []).find((tpc: Maybe<Topic>) => tpc?.slug === mainTopicSlug)
2024-02-04 17:40:15 +00:00
const mainTopicTitle =
mainTopicSlug && lng === 'en' ? mainTopicSlug.replace(/-/, ' ') : mainTopic?.title || ''
return [mainTopicTitle, mainTopicSlug]
}
2024-06-24 17:50:27 +00:00
const LAYOUT_ASPECT: { [key: string]: string } = {
2024-02-04 17:40:15 +00:00
music: styles.aspectRatio1x1,
2024-05-10 12:59:21 +00:00
audio: styles.aspectRatio1x1,
2024-02-04 17:40:15 +00:00
literature: styles.aspectRatio16x9,
video: styles.aspectRatio16x9,
2024-06-26 08:22:05 +00:00
image: styles.aspectRatio4x3
2024-02-04 17:40:15 +00:00
}
2022-09-22 09:37:49 +00:00
export const ArticleCard = (props: ArticleCardProps) => {
const { t, lang, formatDate } = useLocalize()
2024-06-24 17:50:27 +00:00
const { session } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const [, changeSearchParams] = useSearchParams()
2024-02-04 17:40:15 +00:00
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const [isCoverImageLoadError, setIsCoverImageLoadError] = createSignal(false)
const [isCoverImageLoading, setIsCoverImageLoading] = createSignal(true)
2024-05-06 23:44:06 +00:00
const description = getDescription(props.article?.body)
2024-06-24 17:50:27 +00:00
const aspectRatio: Accessor<string> = () => LAYOUT_ASPECT[props.article?.layout as string]
2024-02-04 17:40:15 +00:00
const [mainTopicTitle, mainTopicSlug] = getMainTopicTitle(props.article, lang())
const { title, subtitle } = getTitleAndSubtitle(props.article)
2022-09-22 09:37:49 +00:00
const formattedDate = createMemo<string>(() =>
2024-06-26 08:22:05 +00:00
props.article?.published_at ? formatDate(new Date(props.article.published_at * 1000)) : ''
)
2022-09-22 09:37:49 +00:00
2024-02-16 17:04:05 +00:00
const canEdit = createMemo(
() =>
Boolean(author()?.id) &&
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
props.article?.created_by?.id === author().id ||
2024-06-26 08:22:05 +00:00
session()?.user?.roles?.includes('editor'))
2024-02-16 10:21:25 +00:00
)
2024-06-24 17:50:27 +00:00
const navigate = useNavigate()
const scrollToComments = (event: MouseEvent & { currentTarget: HTMLAnchorElement; target: Element }) => {
2023-04-20 14:01:15 +00:00
event.preventDefault()
2024-06-24 17:50:27 +00:00
navigate(`/article/${props.article.slug}`)
changeSearchParams({
2024-06-26 08:22:05 +00:00
scrollTo: 'comments'
})
2023-04-20 14:01:15 +00:00
}
2024-02-17 15:44:56 +00:00
2024-06-24 17:50:27 +00:00
const onInvite = () => {
if (props.onInvite) props.onInvite()
}
2022-09-09 11:53:35 +00:00
return (
<section
class={clsx(styles.shoutCard, props.settings?.additionalClass, {
2022-10-25 21:45:37 +00:00
[styles.shoutCardShort]: props.settings?.isShort,
[styles.shoutCardPhotoBottom]: props.settings?.noimage && props.settings?.photoBottom,
[styles.shoutCardFeed]: props.settings?.isFeedMode,
[styles.shoutCardFloorImportant]: props.settings?.isFloorImportant,
[styles.shoutCardWithCover]: props.settings?.isWithCover,
[styles.shoutCardBigTitle]: props.settings?.isBigTitle,
[styles.shoutCardVertical]: props.settings?.isVertical,
[styles.shoutCardWithBorder]: props.settings?.withBorder,
[styles.shoutCardCompact]: props.settings?.isCompact,
2022-11-16 21:08:04 +00:00
[styles.shoutCardSingle]: props.settings?.isSingle,
[styles.shoutCardBeside]: props.settings?.isBeside,
[styles.shoutCardNoImage]: !props.article.cover,
2024-06-26 08:22:05 +00:00
[aspectRatio()]: props.withAspectRatio
})}
2022-09-09 11:53:35 +00:00
>
2024-02-17 15:44:56 +00:00
{/* Cover Image */}
2024-02-04 17:40:15 +00:00
<Show when={!(props.settings?.noimage || props.settings?.isFeedMode)}>
2024-02-17 15:44:56 +00:00
{/* Cover Image Container */}
2022-10-25 21:45:37 +00:00
<div class={styles.shoutCardCoverContainer}>
<div
class={clsx(styles.shoutCardCover, {
2024-06-26 08:22:05 +00:00
[styles.loading]: props.article.cover && isCoverImageLoading()
})}
>
<Show
when={props.article.cover && !isCoverImageLoadError()}
fallback={<CoverImage class={styles.placeholderCoverImage} />}
>
<Image
2024-06-24 17:50:27 +00:00
src={props.article.cover || ''}
alt={title}
2024-06-24 17:50:27 +00:00
width={desktopCoverImageWidths[props.desktopCoverSize || 'M']}
onError={() => {
setIsCoverImageLoadError(true)
setIsCoverImageLoading(false)
}}
onLoad={() => setIsCoverImageLoading(false)}
/>
2023-08-12 14:17:00 +00:00
</Show>
2022-09-28 10:34:21 +00:00
</div>
</div>
</Show>
2024-02-17 15:44:56 +00:00
{/* Shout Card Content */}
2022-10-25 21:45:37 +00:00
<div class={styles.shoutCardContent}>
2024-02-17 15:44:56 +00:00
{/* Shout Card Icon */}
2023-06-21 20:32:16 +00:00
<Show
when={
props.article.layout &&
props.article.layout !== 'article' &&
2023-06-21 20:32:16 +00:00
!(props.settings?.noicon || props.settings?.noimage) &&
!props.settings?.isFeedMode
}
>
2022-10-25 21:45:37 +00:00
<div class={styles.shoutCardType}>
<a href={`/expo/${props.article.layout}`}>
<Icon name={props.article.layout} class={styles.icon} />
2024-02-17 18:57:02 +00:00
{/*<Icon name={`${layout}-hover`} class={clsx(styles.icon, styles.iconHover)} />*/}
2022-09-28 10:34:21 +00:00
</a>
2022-09-09 11:53:35 +00:00
</div>
</Show>
2024-02-17 15:44:56 +00:00
{/* Main Topic */}
2023-12-09 18:35:08 +00:00
<Show when={!props.settings?.isGroup && mainTopicSlug}>
2022-10-19 14:26:49 +00:00
<CardTopic
2023-12-09 18:35:08 +00:00
title={mainTopicTitle}
slug={mainTopicSlug}
2022-10-19 14:26:49 +00:00
isFloorImportant={props.settings?.isFloorImportant}
2023-06-21 20:32:16 +00:00
isFeedMode={true}
2024-06-24 17:50:27 +00:00
class={clsx(styles.shoutTopic, { [styles.shoutTopicTop]: props.settings?.isShort })}
2022-10-19 14:26:49 +00:00
/>
2022-09-28 10:34:21 +00:00
</Show>
2022-09-09 11:53:35 +00:00
2024-02-17 15:44:56 +00:00
{/* Title and Subtitle */}
2023-06-21 20:32:16 +00:00
<div
class={clsx(styles.shoutCardTitlesContainer, {
2024-06-26 08:22:05 +00:00
[styles.shoutCardTitlesContainerFeedMode]: props.settings?.isFeedMode
2023-06-21 20:32:16 +00:00
})}
>
2024-06-28 07:47:38 +00:00
<A href={`/article${props.article?.slug || ''}`}>
2022-10-25 21:45:37 +00:00
<div class={styles.shoutCardTitle}>
2023-07-09 18:34:59 +00:00
<span class={styles.shoutCardLinkWrapper}>
2024-01-25 15:06:26 +00:00
<span class={styles.shoutCardLinkContainer} innerHTML={title} />
2023-07-09 18:34:59 +00:00
</span>
2022-09-09 11:53:35 +00:00
</div>
2022-09-28 10:34:21 +00:00
<Show when={!props.settings?.nosubtitle && subtitle}>
2022-10-25 21:45:37 +00:00
<div class={styles.shoutCardSubtitle}>
2024-06-28 07:47:38 +00:00
<span class={styles.shoutCardLinkContainer} innerHTML={subtitle || ''} />
2022-09-09 11:53:35 +00:00
</div>
2022-09-28 10:34:21 +00:00
</Show>
2024-06-24 17:50:27 +00:00
</A>
2022-09-28 10:34:21 +00:00
</div>
2024-02-17 15:44:56 +00:00
{/* Details */}
2024-02-04 17:40:15 +00:00
<Show when={!(props.settings?.noauthor && props.settings?.nodate)}>
2024-02-17 15:44:56 +00:00
{/* Author and Date */}
2023-06-21 20:32:16 +00:00
<div
class={clsx(styles.shoutDetails, { [styles.shoutDetailsFeedMode]: props.settings?.isFeedMode })}
>
2022-09-28 10:34:21 +00:00
<Show when={!props.settings?.noauthor}>
2022-10-25 21:45:37 +00:00
<div class={styles.shoutAuthor}>
<For each={props.article.authors}>
2024-06-24 17:50:27 +00:00
{(a: Maybe<Author>) => (
2024-02-17 15:44:56 +00:00
<AuthorLink
size={'XS'}
2024-06-24 17:50:27 +00:00
author={a as Author}
isFloorImportant={Boolean(
2024-06-26 08:22:05 +00:00
props.settings?.isFloorImportant || props.settings?.isWithCover
2024-06-24 17:50:27 +00:00
)}
2024-02-17 15:44:56 +00:00
/>
)}
2022-09-28 10:34:21 +00:00
</For>
</div>
</Show>
<Show when={!props.settings?.nodate}>
<time class={styles.shoutDate}>{formattedDate()}</time>
2022-09-28 10:34:21 +00:00
</Show>
2022-09-09 11:53:35 +00:00
</div>
2022-09-28 10:34:21 +00:00
</Show>
2024-02-17 15:44:56 +00:00
{/* Description */}
<Show when={props.article.description}>
2024-06-24 17:50:27 +00:00
<section class={styles.shoutCardDescription} innerHTML={props.article.description || ''} />
</Show>
2022-09-28 10:34:21 +00:00
<Show when={props.settings?.isFeedMode}>
<Show when={props.article.description}>
2024-06-24 17:50:27 +00:00
<section class={styles.shoutCardDescription} innerHTML={props.article.description || ''} />
</Show>
<Show when={!props.settings?.noimage && props.article.cover}>
2023-06-21 20:32:16 +00:00
<div class={styles.shoutCardCoverContainer}>
<Show
when={
props.article.layout &&
props.article.layout !== 'article' &&
!(props.settings?.noicon || props.settings?.noimage)
2023-06-21 20:32:16 +00:00
}
>
<div class={styles.shoutCardType}>
<a href={`/expo/${props.article.layout}`}>
<Icon name={props.article.layout} class={styles.icon} />
2023-07-09 18:34:59 +00:00
{/*<Icon name={`${layout}-hover`} class={clsx(styles.icon, styles.iconHover)} />*/}
2023-06-21 20:32:16 +00:00
</a>
</div>
</Show>
<div class={styles.shoutCardCover}>
2024-06-24 17:50:27 +00:00
<Image src={props.article.cover || ''} alt={title} width={600} loading="lazy" />
2023-06-21 20:32:16 +00:00
</div>
</div>
</Show>
2023-05-16 19:17:47 +00:00
<section
class={styles.shoutCardDetails}
classList={{ [styles.shoutCardDetailsActive]: isActionPopupActive() }}
2023-05-16 19:17:47 +00:00
>
2022-10-25 21:45:37 +00:00
<div class={styles.shoutCardDetailsContent}>
<ShoutRatingControl shout={props.article} class={styles.shoutCardDetailsItem} />
2023-01-25 22:13:01 +00:00
2022-10-25 21:45:37 +00:00
<div class={clsx(styles.shoutCardDetailsItem, styles.shoutCardComments)}>
2023-04-20 14:01:15 +00:00
<a href="#" onClick={(event) => scrollToComments(event)}>
2023-01-25 22:13:01 +00:00
<Icon name="comment" class={clsx(styles.icon, styles.feedControlIcon)} />
<Icon
name="comment-hover"
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
/>
<Show
when={props.article.stat?.commented}
fallback={
<span class={clsx(styles.shoutCardLinkContainer, styles.shoutCardDetailsItemLabel)}>
{t('Add comment')}
</span>
}
>
{props.article.stat?.commented}
</Show>
</a>
2022-09-09 11:53:35 +00:00
</div>
2023-07-09 18:34:59 +00:00
<Show when={props.settings?.withViewed}>
<div class={clsx(styles.shoutCardDetailsItem, styles.shoutCardDetailsViewed)}>
<Icon name="eye" class={clsx(styles.icon, styles.feedControlIcon)} />
{props.article.stat?.viewed}
2023-07-09 18:34:59 +00:00
</div>
</Show>
2023-01-25 22:13:01 +00:00
</div>
2022-09-09 11:53:35 +00:00
2023-01-25 22:13:01 +00:00
<div class={styles.shoutCardDetailsContent}>
<Show when={canEdit()}>
<Popover content={t('Edit')} disabled={isActionPopupActive()}>
2024-06-24 17:50:27 +00:00
{(triggerRef: (el: HTMLElement) => void) => (
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
2024-06-24 17:50:27 +00:00
<A href={`/edit/${props.article?.id}`}>
<Icon name="pencil-outline" class={clsx(styles.icon, styles.feedControlIcon)} />
<Icon
name="pencil-outline-hover"
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
/>
2024-06-24 17:50:27 +00:00
</A>
</div>
)}
</Popover>
</Show>
2022-09-28 10:34:21 +00:00
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
2024-06-24 17:50:27 +00:00
{(triggerRef: (el: HTMLElement) => void) => (
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
2023-01-25 22:13:01 +00:00
<button>
<Icon name="bookmark" class={clsx(styles.icon, styles.feedControlIcon)} />
<Icon
name="bookmark-hover"
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
/>
2023-01-25 22:13:01 +00:00
</button>
</div>
)}
</Popover>
<Popover content={t('Share')} disabled={isActionPopupActive()}>
2024-06-24 17:50:27 +00:00
{(triggerRef: (el: HTMLElement) => void) => (
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
<SharePopup
containerCssClass={stylesHeader.control}
title={title}
description={description}
2024-06-24 17:50:27 +00:00
imageUrl={props.article.cover || ''}
shareUrl={getShareUrl({ pathname: `/${props.article.slug}` })}
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
trigger={
<button>
<Icon name="share-outline" class={clsx(styles.icon, styles.feedControlIcon)} />
<Icon
name="share-outline-hover"
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
/>
</button>
}
/>
</div>
)}
</Popover>
2023-01-25 22:13:01 +00:00
<div class={styles.shoutCardDetailsItem}>
2023-02-06 21:35:08 +00:00
<FeedArticlePopup
2024-06-24 17:50:27 +00:00
canEdit={Boolean(canEdit())}
2023-02-06 21:35:08 +00:00
containerCssClass={stylesHeader.control}
2024-06-24 17:50:27 +00:00
onShareClick={() => props.onShare?.(props.article)}
onInviteClick={onInvite}
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
2023-02-06 21:35:08 +00:00
trigger={
<button>
<Icon name="ellipsis" class={clsx(styles.icon, styles.feedControlIcon)} />
<Icon
name="ellipsis"
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
/>
2023-02-06 21:35:08 +00:00
</button>
}
/>
2023-01-25 22:13:01 +00:00
</div>
</div>
2022-09-28 10:34:21 +00:00
</section>
</Show>
</div>
2022-09-09 11:53:35 +00:00
</section>
)
}