import { AuthToken } from '@authorizerdev/authorizer-js' import { Link } from '@solidjs/meta' import { A, useSearchParams } from '@solidjs/router' import { clsx } from 'clsx' import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js' import { isServer } from 'solid-js/web' import { useFeed } from '~/context/feed' import { useLocalize } from '~/context/localize' import { useReactions } from '~/context/reactions' import { useSession } from '~/context/session' import { DEFAULT_HEADER_OFFSET, useUI } from '~/context/ui' import type { Author, Maybe, Shout, Topic } from '~/graphql/schema/core.gen' import { processPrepositions } from '~/intl/prepositions' import { isCyrillic } from '~/intl/translate' import { createTooltip } from '~/lib/createTooltip' import { getImageUrl } from '~/lib/getThumbUrl' import { MediaItem } from '~/types/mediaitem' import { capitalize } from '~/utils/capitalize' import { AuthorBadge } from '../Author/AuthorBadge' import { CardTopic } from '../Feed/CardTopic' import { FeedArticlePopup } from '../Feed/FeedArticlePopup' import { Icon } from '../_shared/Icon' import { Image } from '../_shared/Image' import { InviteMembers } from '../_shared/InviteMembers' import { Lightbox } from '../_shared/Lightbox' import { Modal } from '../_shared/Modal' import { Popover } from '../_shared/Popover' import { ShareModal } from '../_shared/ShareModal' import { ImageSwiper } from '../_shared/SolidSwiper' import { TableOfContents } from '../_shared/TableOfContents' import { VideoPlayer } from '../_shared/VideoPlayer' import { AudioHeader } from './AudioHeader' import { AudioPlayer } from './AudioPlayer' import { CommentsTree } from './CommentsTree' import { SharePopup, getShareUrl } from './SharePopup' import { ShoutRatingControl } from './ShoutRatingControl' import stylesHeader from '../HeaderNav/Header.module.scss' import styles from './Article.module.scss' type Props = { article: Shout } type IframeSize = { width: number height: number } export type ArticlePageSearchParams = { commentId?: string slide?: string } const scrollTo = (el: HTMLElement) => { const { top } = el.getBoundingClientRect() window?.scrollTo({ top: top + window.scrollY - DEFAULT_HEADER_OFFSET, left: 0, behavior: 'smooth' }) } const imgSrcRegExp = /]+src\s*=\s*["']([^"']+)["']/gi export const COMMENTS_PER_PAGE = 30 const VOTES_PER_PAGE = 50 export const FullArticle = (props: Props) => { const [searchParams] = useSearchParams() const { showModal } = useUI() const { loadReactionsBy } = useReactions() const [selectedImage, setSelectedImage] = createSignal('') const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false) const [isActionPopupActive, setIsActionPopupActive] = createSignal(false) const { t, formatDate, lang } = useLocalize() const { session, requireAuthentication } = useSession() const { addSeen } = useFeed() const formattedDate = createMemo(() => formatDate(new Date((props.article.published_at || 0) * 1000))) const [pages, setPages] = createSignal>({}) createEffect( on( pages, (p: Record) => { console.debug('content paginated') loadReactionsBy({ by: { shout: props.article.slug, comment: true }, limit: COMMENTS_PER_PAGE, offset: COMMENTS_PER_PAGE * p.comments || 0 }) loadReactionsBy({ by: { shout: props.article.slug, rating: true }, limit: VOTES_PER_PAGE, offset: VOTES_PER_PAGE * p.rating || 0 }) setIsReactionsLoaded(true) console.debug('reactions paginated') }, { defer: true } ) ) const [canEdit, setCanEdit] = createSignal(false) createEffect( on( () => session(), (s?: AuthToken) => { const profile = s?.user?.app_data?.profile if (!profile) return const isEditor = s?.user?.roles?.includes('editor') const isCreator = props.article.created_by?.id === profile.id const fit = (a: Maybe) => a?.id === profile.id || isCreator || isEditor setCanEdit((_: boolean) => Boolean(props.article.authors?.some(fit))) } ) ) const mainTopic = createMemo(() => { const mainTopicSlug = (props.article.topics?.length || 0) > 0 ? props.article.main_topic : null const mt = props.article.topics?.find((tpc: Maybe) => tpc?.slug === mainTopicSlug) if (mt) { mt.title = lang() === 'en' ? capitalize(mt.slug.replaceAll('-', ' ')) : mt.title return mt } return props.article.topics?.[0] }) const handleBookmarkButtonClick = (ev: MouseEvent | undefined) => { requireAuthentication(() => { // TODO: implement bookmark clicked ev?.preventDefault() }, 'bookmark') } const body = createMemo(() => { if (props.article.layout === 'literature') { try { if (props.article.media) { const media = JSON.parse(props.article.media) if (media.length > 0) { return processPrepositions(media[0].body) } } } catch (error) { console.error(error) } } return processPrepositions(props.article.body) || '' }) const imageUrls = createMemo(() => { if (!body()) { return [] } if (isServer) { const result: string[] = [] let match: RegExpMatchArray | null while ((match = imgSrcRegExp.exec(body())) !== null) { if (match) result.push(match[1]) else break } return result } const imageElements = document.querySelectorAll('#shoutBody img') // eslint-disable-next-line unicorn/prefer-spread return Array.from(imageElements).map((img) => img.src) }) const media = createMemo(() => JSON.parse(props.article.media || '[]')) let commentsRef: HTMLDivElement | undefined createEffect(() => { if (searchParams?.commentId && isReactionsLoaded()) { console.debug('comment id is in link, scroll to') const scrollToElement = document.querySelector(`[id='comment_${searchParams?.commentId}']`) || commentsRef || document.body if (scrollToElement) { requestAnimationFrame(() => scrollTo(scrollToElement)) } } }) const clickHandlers: { element: HTMLElement; handler: () => void }[] = [] const documentClickHandlers: ((e: MouseEvent) => void)[] = [] createEffect(() => { if (!body()) { return } const tooltipElements: NodeListOf = document.querySelectorAll( '[data-toggle="tooltip"], footnote' ) if (!tooltipElements) { return } tooltipElements.forEach((element) => { const tooltip = document.createElement('div') tooltip.classList.add(styles.tooltip) const tooltipContent = document.createElement('div') tooltipContent.classList.add(styles.tooltipContent) tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value || '' tooltip.append(tooltipContent) document.body.append(tooltip) if (element.hasAttribute('href')) { element.setAttribute('href', 'javascript: void(0)') } const popperInstance = createTooltip(element, tooltip, { placement: 'top', modifiers: [ { name: 'eventListeners', options: { scroll: false } }, { name: 'offset', options: { offset: [0, 8] } }, { name: 'flip', options: { fallbackPlacements: ['top'] } } ] }) tooltip.style.visibility = 'hidden' let isTooltipVisible = false const handleClick = () => { if (isTooltipVisible) { tooltip.style.visibility = 'hidden' isTooltipVisible = false } else { tooltip.style.visibility = 'visible' isTooltipVisible = true } popperInstance.update() } const handleDocumentClick = (e: MouseEvent) => { if (isTooltipVisible && e.target !== element && e.target !== tooltip) { tooltip.style.visibility = 'hidden' isTooltipVisible = false } } element.addEventListener('click', handleClick) document.addEventListener('click', handleDocumentClick) clickHandlers.push({ element, handler: handleClick }) documentClickHandlers.push(handleDocumentClick) }) }) onCleanup(() => { clickHandlers.forEach(({ element, handler }) => { element.removeEventListener('click', handler) }) documentClickHandlers.forEach((handler) => { document.removeEventListener('click', handler) }) }) const openLightbox = (image: string) => { setSelectedImage(image) } const handleLightboxClose = () => { setSelectedImage('') } // biome-ignore lint/suspicious/noExplicitAny: FIXME: typing const handleArticleBodyClick = (event: any) => { if (event.target.tagName === 'IMG' && !event.target.dataset.disableLightbox) { const src = event.target.src openLightbox(getImageUrl(src)) } } // Check iframes size let articleContainer: HTMLElement | undefined const updateIframeSizes = () => { if (!window) return if (!(articleContainer && props.article.body)) return const iframes = articleContainer?.querySelectorAll('iframe') if (!iframes) return const containerWidth = articleContainer?.offsetWidth iframes.forEach((iframe) => { const style = window.getComputedStyle(iframe) const originalWidth = iframe.getAttribute('width') || style.width.replace('px', '') const originalHeight = iframe.getAttribute('height') || style.height.replace('px', '') const width: IframeSize['width'] = Number(originalWidth) const height: IframeSize['height'] = Number(originalHeight) if (containerWidth < width) { const aspectRatio = width / height iframe.style.width = `${containerWidth}px` iframe.style.height = `${Math.round(containerWidth / aspectRatio) + 40}px` } else { iframe.style.height = `${containerWidth}px` } }) } onMount(() => { console.debug(props.article) setPages((_) => ({ comments: 0, rating: 0 })) addSeen(props.article.slug) document.title = props.article.title updateIframeSizes() window?.addEventListener('resize', updateIframeSizes) onCleanup(() => window.removeEventListener('resize', updateIframeSizes)) }) const shareUrl = createMemo(() => getShareUrl({ pathname: `/${props.article.slug || ''}` })) const getAuthorName = (a: Author) => lang() === 'en' && isCyrillic(a.name || '') ? capitalize(a.slug.replaceAll('-', ' ')) : a.name return ( <> {(imageUrl) => }
(articleContainer = el)} class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)} onClick={handleArticleBodyClick} > {/*TODO: Check styles.shoutTopic*/}

{props.article.title || ''}

{processPrepositions(props.article.subtitle || '')}

{(a: Maybe, index: () => number) => ( <> 0}>, {a && getAuthorName(a)} )}
{props.article.cover_caption
0}>
{(m: MediaItem) => (
)}
{(triggerRef: (el: HTMLElement) => void) => (
commentsRef && scrollTo(commentsRef)} > {t('Add comment')}} > {props.article.stat?.commented}
)}
{t('some views', { count: props.article.stat?.viewed || 0 })}
{formattedDate()}
{(triggerRef: (el: HTMLElement) => void) => (
)}
{(triggerRef: (el: HTMLElement) => void) => (
setIsActionPopupActive(isVisible)} trigger={
} />
)}
{(triggerRef: (el: HTMLElement) => void) => ( )} showModal('share')} onInviteClick={() => showModal('inviteMembers')} onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)} trigger={ } />
1}>

{t('Authors')}

{(a: Maybe) => (
)}
(commentsRef = el)}>
) }