410 lines
16 KiB
TypeScript
410 lines
16 KiB
TypeScript
import { A, useNavigate, useSearchParams } from '@solidjs/router'
|
|
import { clsx } from 'clsx'
|
|
import { Accessor, For, Show, createMemo, createSignal } from 'solid-js'
|
|
import { Icon } from '~/components/_shared/Icon'
|
|
import { Image } from '~/components/_shared/Image'
|
|
import { Popover } from '~/components/_shared/Popover'
|
|
import { useLocalize } from '~/context/localize'
|
|
import { useSession } from '~/context/session'
|
|
import type { Author, Maybe, Shout, Topic } from '~/graphql/schema/core.gen'
|
|
import { capitalize } from '~/utils/capitalize'
|
|
import { descFromBody } from '~/utils/meta'
|
|
import { CoverImage } from '../../Article/CoverImage'
|
|
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
|
|
import { ShoutRatingControl } from '../../Article/ShoutRatingControl'
|
|
import { AuthorLink } from '../../Author/AuthorLink'
|
|
import stylesHeader from '../../Nav/Header/Header.module.scss'
|
|
import { CardTopic } from '../CardTopic'
|
|
import { FeedArticlePopup } from '../FeedArticlePopup'
|
|
import styles from './ArticleCard.module.scss'
|
|
|
|
export type ArticleCardProps = {
|
|
// TODO: refactor this, please
|
|
settings?: {
|
|
noicon?: boolean
|
|
noimage?: boolean
|
|
nosubtitle?: boolean
|
|
noauthor?: boolean
|
|
nodate?: boolean
|
|
isGroup?: boolean
|
|
photoBottom?: boolean
|
|
additionalClass?: string
|
|
isFeedMode?: boolean
|
|
isFloorImportant?: boolean
|
|
isWithCover?: boolean
|
|
isBigTitle?: boolean
|
|
isVertical?: boolean
|
|
isShort?: boolean
|
|
withBorder?: boolean
|
|
isCompact?: boolean
|
|
isSingle?: boolean
|
|
isBeside?: boolean
|
|
withViewed?: boolean
|
|
noAuthorLink?: boolean
|
|
}
|
|
withAspectRatio?: boolean
|
|
desktopCoverSize?: string // 'XS' | 'S' | 'M' | 'L'
|
|
article: Shout
|
|
onShare?: (article: Shout) => void
|
|
onInvite?: () => void
|
|
}
|
|
|
|
const desktopCoverImageWidths: Record<string, number> = {
|
|
XS: 300,
|
|
S: 400,
|
|
M: 600,
|
|
L: 800
|
|
}
|
|
|
|
const getTitleAndSubtitle = (
|
|
article: Shout
|
|
): {
|
|
title: string
|
|
subtitle: string
|
|
} => {
|
|
let title = article?.title || ''
|
|
let subtitle: string = article?.subtitle || ''
|
|
|
|
if (!subtitle) {
|
|
let titleParts = article.title?.split('. ') || []
|
|
|
|
if (titleParts?.length === 1) {
|
|
titleParts = article.title?.split(/{!|\?|:|;}\s/) || []
|
|
}
|
|
|
|
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) || ''
|
|
}
|
|
}
|
|
|
|
// TODO: simple fast auto translated title/substitle
|
|
|
|
return { title, subtitle }
|
|
}
|
|
|
|
const getMainTopicTitle = (article: Shout, lng: string) => {
|
|
const mainTopicSlug = article?.main_topic || ''
|
|
const mainTopic = (article?.topics || []).find((tpc: Maybe<Topic>) => tpc?.slug === mainTopicSlug)
|
|
const mainTopicTitle =
|
|
mainTopicSlug && lng === 'en' ? mainTopicSlug.replace(/-/, ' ') : mainTopic?.title || ''
|
|
|
|
return [mainTopicTitle, mainTopicSlug]
|
|
}
|
|
|
|
const LAYOUT_ASPECT: { [key: string]: string } = {
|
|
music: styles.aspectRatio1x1,
|
|
audio: styles.aspectRatio1x1,
|
|
literature: styles.aspectRatio16x9,
|
|
video: styles.aspectRatio16x9,
|
|
image: styles.aspectRatio4x3
|
|
}
|
|
|
|
export const ArticleCard = (props: ArticleCardProps) => {
|
|
const { t, lang, formatDate } = useLocalize()
|
|
const { session } = useSession()
|
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
|
const [, changeSearchParams] = useSearchParams()
|
|
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
|
|
const [isCoverImageLoadError, setIsCoverImageLoadError] = createSignal(false)
|
|
const [isCoverImageLoading, setIsCoverImageLoading] = createSignal(true)
|
|
const description = descFromBody(props.article?.body)
|
|
const aspectRatio: Accessor<string> = () => LAYOUT_ASPECT[props.article?.layout as string]
|
|
const [mainTopicTitle, mainTopicSlug] = getMainTopicTitle(props.article, lang())
|
|
const { title, subtitle } = getTitleAndSubtitle(props.article)
|
|
|
|
const formattedDate = createMemo<string>(() =>
|
|
props.article?.published_at ? formatDate(new Date(props.article.published_at * 1000)) : ''
|
|
)
|
|
|
|
const canEdit = createMemo(
|
|
() =>
|
|
Boolean(author()?.id) &&
|
|
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
|
|
props.article?.created_by?.id === author().id ||
|
|
session()?.user?.roles?.includes('editor'))
|
|
)
|
|
const navigate = useNavigate()
|
|
const scrollToComments = (event: MouseEvent & { currentTarget: HTMLAnchorElement; target: Element }) => {
|
|
event.preventDefault()
|
|
navigate(`/${props.article.slug}`)
|
|
changeSearchParams({
|
|
scrollTo: 'comments'
|
|
})
|
|
}
|
|
|
|
const onInvite = () => {
|
|
if (props.onInvite) props.onInvite()
|
|
}
|
|
|
|
return (
|
|
<section
|
|
class={clsx(styles.shoutCard, props.settings?.additionalClass, {
|
|
[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,
|
|
[styles.shoutCardSingle]: props.settings?.isSingle,
|
|
[styles.shoutCardBeside]: props.settings?.isBeside,
|
|
[styles.shoutCardNoImage]: !props.article.cover,
|
|
[aspectRatio()]: props.withAspectRatio
|
|
})}
|
|
>
|
|
{/* Cover Image */}
|
|
<Show when={!(props.settings?.noimage || props.settings?.isFeedMode)}>
|
|
{/* Cover Image Container */}
|
|
<div class={styles.shoutCardCoverContainer}>
|
|
<div
|
|
class={clsx(styles.shoutCardCover, {
|
|
[styles.loading]: props.article.cover && isCoverImageLoading()
|
|
})}
|
|
>
|
|
<Show
|
|
when={props.article.cover && !isCoverImageLoadError()}
|
|
fallback={<CoverImage class={styles.placeholderCoverImage} />}
|
|
>
|
|
<Image
|
|
src={props.article.cover || ''}
|
|
alt={title}
|
|
width={desktopCoverImageWidths[props.desktopCoverSize || 'M']}
|
|
onError={() => {
|
|
setIsCoverImageLoadError(true)
|
|
setIsCoverImageLoading(false)
|
|
}}
|
|
onLoad={() => setIsCoverImageLoading(false)}
|
|
/>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Shout Card Content */}
|
|
<div class={styles.shoutCardContent}>
|
|
{/* Shout Card Icon */}
|
|
<Show
|
|
when={
|
|
props.article.layout &&
|
|
props.article.layout !== 'article' &&
|
|
!(props.settings?.noicon || props.settings?.noimage) &&
|
|
!props.settings?.isFeedMode
|
|
}
|
|
>
|
|
<div class={styles.shoutCardType}>
|
|
<a href={`/expo/${props.article.layout}`}>
|
|
<Icon name={props.article.layout} class={styles.icon} />
|
|
{/*<Icon name={`${layout}-hover`} class={clsx(styles.icon, styles.iconHover)} />*/}
|
|
</a>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Main Topic */}
|
|
<Show when={!props.settings?.isGroup && mainTopicSlug}>
|
|
<CardTopic
|
|
title={mainTopicTitle}
|
|
slug={mainTopicSlug}
|
|
isFloorImportant={props.settings?.isFloorImportant}
|
|
isFeedMode={true}
|
|
class={clsx(styles.shoutTopic, { [styles.shoutTopicTop]: props.settings?.isShort })}
|
|
/>
|
|
</Show>
|
|
|
|
{/* Title and Subtitle */}
|
|
<div
|
|
class={clsx(styles.shoutCardTitlesContainer, {
|
|
[styles.shoutCardTitlesContainerFeedMode]: props.settings?.isFeedMode
|
|
})}
|
|
>
|
|
<A href={`/${props.article?.slug || ''}`}>
|
|
<div class={styles.shoutCardTitle}>
|
|
<span class={styles.shoutCardLinkWrapper}>
|
|
<span class={styles.shoutCardLinkContainer} innerHTML={title} />
|
|
</span>
|
|
</div>
|
|
|
|
<Show when={!props.settings?.nosubtitle && subtitle}>
|
|
<div class={styles.shoutCardSubtitle}>
|
|
<span class={styles.shoutCardLinkContainer} innerHTML={subtitle || ''} />
|
|
</div>
|
|
</Show>
|
|
</A>
|
|
</div>
|
|
|
|
{/* Details */}
|
|
<Show when={!(props.settings?.noauthor && props.settings?.nodate)}>
|
|
{/* Author and Date */}
|
|
<div
|
|
class={clsx(styles.shoutDetails, { [styles.shoutDetailsFeedMode]: props.settings?.isFeedMode })}
|
|
>
|
|
<Show when={!props.settings?.noauthor}>
|
|
<div class={styles.shoutAuthor}>
|
|
<For each={props.article.authors}>
|
|
{(a: Maybe<Author>) => (
|
|
<AuthorLink
|
|
size={'XS'}
|
|
author={a as Author}
|
|
isFloorImportant={Boolean(
|
|
props.settings?.isFloorImportant || props.settings?.isWithCover
|
|
)}
|
|
/>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
<Show when={!props.settings?.nodate}>
|
|
<time class={styles.shoutDate}>{formattedDate()}</time>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Description */}
|
|
<Show when={props.article.description}>
|
|
<section class={styles.shoutCardDescription} innerHTML={props.article.description || ''} />
|
|
</Show>
|
|
<Show when={props.settings?.isFeedMode}>
|
|
<Show when={props.article.description}>
|
|
<section class={styles.shoutCardDescription} innerHTML={props.article.description || ''} />
|
|
</Show>
|
|
<Show when={!props.settings?.noimage && props.article.cover}>
|
|
<div class={styles.shoutCardCoverContainer}>
|
|
<Show
|
|
when={
|
|
props.article.layout &&
|
|
props.article.layout !== 'article' &&
|
|
!(props.settings?.noicon || props.settings?.noimage)
|
|
}
|
|
>
|
|
<div class={styles.shoutCardType}>
|
|
<a href={`/expo/${props.article.layout}`}>
|
|
<Icon name={props.article.layout} class={styles.icon} />
|
|
{/*<Icon name={`${layout}-hover`} class={clsx(styles.icon, styles.iconHover)} />*/}
|
|
</a>
|
|
</div>
|
|
</Show>
|
|
<div class={styles.shoutCardCover}>
|
|
<Image src={props.article.cover || ''} alt={title} width={600} loading="lazy" />
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<section
|
|
class={styles.shoutCardDetails}
|
|
classList={{ [styles.shoutCardDetailsActive]: isActionPopupActive() }}
|
|
>
|
|
<div class={styles.shoutCardDetailsContent}>
|
|
<ShoutRatingControl shout={props.article} class={styles.shoutCardDetailsItem} />
|
|
|
|
<div class={clsx(styles.shoutCardDetailsItem, styles.shoutCardComments)}>
|
|
<a href="#" onClick={(event) => scrollToComments(event)}>
|
|
<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>
|
|
</div>
|
|
|
|
<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}
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
<div class={styles.shoutCardDetailsContent}>
|
|
<Show when={canEdit()}>
|
|
<Popover content={t('Edit')} disabled={isActionPopupActive()}>
|
|
{(triggerRef: (el: HTMLElement) => void) => (
|
|
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
|
<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)}
|
|
/>
|
|
</A>
|
|
</div>
|
|
)}
|
|
</Popover>
|
|
</Show>
|
|
|
|
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
|
|
{(triggerRef: (el: HTMLElement) => void) => (
|
|
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
|
<button>
|
|
<Icon name="bookmark" class={clsx(styles.icon, styles.feedControlIcon)} />
|
|
<Icon
|
|
name="bookmark-hover"
|
|
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</Popover>
|
|
|
|
<Popover content={t('Share')} disabled={isActionPopupActive()}>
|
|
{(triggerRef: (el: HTMLElement) => void) => (
|
|
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
|
<SharePopup
|
|
containerCssClass={stylesHeader.control}
|
|
title={title}
|
|
description={description}
|
|
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>
|
|
|
|
<div class={styles.shoutCardDetailsItem}>
|
|
<FeedArticlePopup
|
|
canEdit={Boolean(canEdit())}
|
|
containerCssClass={stylesHeader.control}
|
|
onShareClick={() => props.onShare?.(props.article)}
|
|
onInviteClick={onInvite}
|
|
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
|
|
trigger={
|
|
<button>
|
|
<Icon name="ellipsis" class={clsx(styles.icon, styles.feedControlIcon)} />
|
|
<Icon
|
|
name="ellipsis"
|
|
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
|
|
/>
|
|
</button>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</Show>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|