Merge branch 'dev' into hotfix/correct-following-status

This commit is contained in:
Untone 2024-04-25 13:46:36 +03:00
commit e38d3b39b7
33 changed files with 650 additions and 293 deletions

View File

@ -16,7 +16,7 @@ jobs:
run: npm run typecheck run: npm run typecheck
- name: Lint with Biome - name: Lint with Biome
run: npx biome ci . run: npm run check:code
- name: Lint styles - name: Lint styles
run: npm run lint:styles run: npm run lint:styles

View File

@ -2,7 +2,7 @@
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
"files": { "files": {
"include": ["*.tsx", "*.ts", "*.js", "*.json"], "include": ["*.tsx", "*.ts", "*.js", "*.json"],
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.d.ts"] "ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"]
}, },
"vcs": { "vcs": {
"defaultBranch": "dev", "defaultBranch": "dev",

View File

@ -16,7 +16,7 @@
"hygen": "HYGEN_TMPLS=gen hygen", "hygen": "HYGEN_TMPLS=gen hygen",
"postinstall": "npm run codegen && npx patch-package", "postinstall": "npm run codegen && npx patch-package",
"check:code": "npx @biomejs/biome check src --log-kind=compact --verbose", "check:code": "npx @biomejs/biome check src --log-kind=compact --verbose",
"check:code:fix": "npx @biomejs/biome check src --log-kind=compact --verbose --apply-unsafe", "check:code:fix": "npx @biomejs/biome check src --log-kind=compact",
"lint": "npm run lint:code && stylelint **/*.{scss,css}", "lint": "npm run lint:code && stylelint **/*.{scss,css}",
"lint:code": "npx @biomejs/biome lint src --log-kind=compact --verbose", "lint:code": "npx @biomejs/biome lint src --log-kind=compact --verbose",
"lint:code:fix": "npx @biomejs/biome lint src --apply-unsafe --log-kind=compact --verbose", "lint:code:fix": "npx @biomejs/biome lint src --apply-unsafe --log-kind=compact --verbose",

View File

@ -83,6 +83,7 @@
"Coming soon": "Coming soon", "Coming soon": "Coming soon",
"Comment successfully deleted": "Comment successfully deleted", "Comment successfully deleted": "Comment successfully deleted",
"Commentator": "Commentator", "Commentator": "Commentator",
"Commenting": "Commenting",
"Comments": "Comments", "Comments": "Comments",
"CommentsWithCount": "{count, plural, =0 {{count} comments} one {{count} comment} few {{count} comments} other {{count} comments}}", "CommentsWithCount": "{count, plural, =0 {{count} comments} one {{count} comment} few {{count} comments} other {{count} comments}}",
"Communities": "Communities", "Communities": "Communities",

View File

@ -87,6 +87,7 @@
"Comment successfully deleted": "Комментарий успешно удален", "Comment successfully deleted": "Комментарий успешно удален",
"Comment": "Комментировать", "Comment": "Комментировать",
"Commentator": "Комментатор", "Commentator": "Комментатор",
"Commenting": "Комментирование",
"Comments": "Комментарии", "Comments": "Комментарии",
"CommentsWithCount": "{count, plural, =0 {{count} комментариев} one {{count} комментарий} few {{count} комментария} other {{count} комментариев}}", "CommentsWithCount": "{count, plural, =0 {{count} комментариев} one {{count} комментарий} few {{count} комментария} other {{count} комментариев}}",
"Communities": "Сообщества", "Communities": "Сообщества",

View File

@ -1,2 +1,2 @@
User-agent: * User-agent: *
Allow: / Disallow: /

View File

@ -22,6 +22,7 @@ img {
.articleContent { .articleContent {
img:not([data-disable-lightbox='true']) { img:not([data-disable-lightbox='true']) {
cursor: zoom-in; cursor: zoom-in;
width: 100%;
} }
} }

View File

@ -54,6 +54,7 @@ type IframeSize = {
export type ArticlePageSearchParams = { export type ArticlePageSearchParams = {
scrollTo: 'comments' scrollTo: 'comments'
commentId: string commentId: string
slide?: string
} }
const scrollTo = (el: HTMLElement) => { const scrollTo = (el: HTMLElement) => {
@ -329,7 +330,7 @@ export const FullArticle = (props: Props) => {
width: 1200, width: 1200,
}) })
const description = getDescription(props.article.description || body()) const description = getDescription(props.article.description || body() || media()[0]?.body)
const ogTitle = props.article.title const ogTitle = props.article.title
const keywords = getKeywords(props.article) const keywords = getKeywords(props.article)
const shareUrl = getShareUrl({ pathname: `/${props.article.slug}` }) const shareUrl = getShareUrl({ pathname: `/${props.article.slug}` })

View File

@ -135,7 +135,9 @@ export const AuthorCard = (props: Props) => {
)} )}
</For> </For>
<div class={styles.subscribersCounter}> <div class={styles.subscribersCounter}>
{t('SubscriberWithCount', { count: props.followers.length ?? 0 })} {t('SubscriberWithCount', {
count: props.followers.length ?? 0,
})}
</div> </div>
</a> </a>
</Show> </Show>
@ -170,7 +172,9 @@ export const AuthorCard = (props: Props) => {
}} }}
</For> </For>
<div class={styles.subscribersCounter}> <div class={styles.subscribersCounter}>
{t('SubscriptionWithCount', { count: props?.following.length ?? 0 })} {t('SubscriptionWithCount', {
count: props?.following.length ?? 0,
})}
</div> </div>
</a> </a>
</Show> </Show>
@ -236,7 +240,9 @@ export const AuthorCard = (props: Props) => {
title={props.author.name} title={props.author.name}
description={props.author.bio} description={props.author.bio}
imageUrl={props.author.pic} imageUrl={props.author.pic}
shareUrl={getShareUrl({ pathname: `/author/${props.author.slug}` })} shareUrl={getShareUrl({
pathname: `/author/${props.author.slug}`,
})}
trigger={<Button variant="secondary" value={t('Share')} />} trigger={<Button variant="secondary" value={t('Share')} />}
/> />
</div> </div>
@ -264,13 +270,21 @@ export const AuthorCard = (props: Props) => {
<> <>
<h2>{t('Subscriptions')}</h2> <h2>{t('Subscriptions')}</h2>
<ul class="view-switcher"> <ul class="view-switcher">
<li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'all' })}> <li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'all',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('all')}> <button type="button" onClick={() => setSubscriptionFilter('all')}>
{t('All')} {t('All')}
</button> </button>
<span class="view-switcher__counter">{props.following.length}</span> <span class="view-switcher__counter">{props.following.length}</span>
</li> </li>
<li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'authors' })}> <li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'authors',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('authors')}> <button type="button" onClick={() => setSubscriptionFilter('authors')}>
{t('Authors')} {t('Authors')}
</button> </button>
@ -278,7 +292,11 @@ export const AuthorCard = (props: Props) => {
{props.following.filter((s) => 'name' in s).length} {props.following.filter((s) => 'name' in s).length}
</span> </span>
</li> </li>
<li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'topics' })}> <li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'topics',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('topics')}> <button type="button" onClick={() => setSubscriptionFilter('topics')}>
{t('Topics')} {t('Topics')}
</button> </button>

View File

@ -8,7 +8,7 @@
z-index: 10003; z-index: 10003;
.wide-container { .wide-container {
background: #fff; background: var(--background-color);
@include media-breakpoint-down(lg) { @include media-breakpoint-down(lg) {
padding: 0 divide($container-padding-x, 2); padding: 0 divide($container-padding-x, 2);
@ -114,6 +114,11 @@
position: absolute; position: absolute;
right: 0; right: 0;
} }
.control {
align-items: center;
display: flex;
}
} }
.mainNavigationWrapper { .mainNavigationWrapper {
@ -192,15 +197,8 @@
padding: divide($container-padding-x, 2) !important; padding: divide($container-padding-x, 2) !important;
} }
@include media-breakpoint-up(md) {
span,
button {
padding: 0 0.4rem;
}
}
:global(.view-switcher) { :global(.view-switcher) {
margin: 0 -0.5rem; margin: 0;
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
} }
@ -299,9 +297,6 @@
.burgerContainer { .burgerContainer {
box-sizing: content-box; box-sizing: content-box;
display: inline-flex; display: inline-flex;
padding-left: 0;
// float: right;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
padding-left: divide($container-padding-x, 2); padding-left: divide($container-padding-x, 2);
@ -430,12 +425,15 @@
width: 100%; width: 100%;
@include media-breakpoint-up(xl) { @include media-breakpoint-up(xl) {
right: 2rem; right: 9rem;
} }
.control { .control {
cursor: pointer;
border: 0; border: 0;
cursor: pointer;
height: 3.2rem;
margin: 0 0.6rem;
width: 3.2rem;
&:hover { &:hover {
background: none; background: none;
@ -451,11 +449,7 @@
} }
.control + .control { .control + .control {
margin-left: 1.2rem; margin: 0 0.6rem;
@include media-breakpoint-up(sm) {
margin-left: 2rem;
}
} }
img { img {
@ -497,10 +491,15 @@
} }
} }
.settingsControlContainer {
margin-left: 1rem !important;
margin-right: 2rem !important;
}
.settingsControl { .settingsControl {
border-radius: 100%; border-radius: 100%;
padding: 0.8rem !important;
min-width: 4rem !important; min-width: 4rem !important;
padding: 0.8rem !important;
&:hover { &:hover {
background: var(--background-color-invert); background: var(--background-color-invert);
@ -516,12 +515,18 @@
align-items: center; align-items: center;
border-radius: 100%; border-radius: 100%;
display: flex; display: flex;
height: 2.4em; height: 2.8rem;
justify-content: center; justify-content: center;
margin-left: 0.3rem; margin: 0 0.4rem;
position: relative; position: relative;
transition: margin-left 0.3s; transition: margin-left 0.3s;
width: 2.4em; width: 2.8rem;
@include media-breakpoint-up(md) {
height: 3.2rem;
margin: 0 0.7rem;
width: 3.2rem;
}
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
margin-left: 0.4rem !important; margin-left: 0.4rem !important;
@ -543,12 +548,13 @@
a:link { a:link {
border: none; border: none;
cursor: pointer; cursor: pointer;
height: auto; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100%;
&:hover { &:hover {
background: none !important; background: none;
.icon { .icon {
display: none; display: none;
@ -571,6 +577,20 @@
} }
} }
.userControlItemSearch {
margin: 0 1rem 0 2.2rem;
}
.userControlItemUserpic {
height: 3.2rem;
width: 3.2rem;
@include media-breakpoint-up(md) {
height: 4rem;
width: 4rem;
}
}
.userControlItemInbox, .userControlItemInbox,
.userControlItemSearch { .userControlItemSearch {
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
@ -579,7 +599,16 @@
} }
.userControlItemVerbose { .userControlItemVerbose {
margin-left: 0.9em !important; align-items: stretch;
display: flex;
height: 3.2rem;
margin-left: 1rem !important;
width: 3.2rem;
@include media-breakpoint-up(md) {
height: 4rem;
width: 4rem;
}
&:first-child { &:first-child {
margin-left: 0 !important; margin-left: 0 !important;
@ -590,6 +619,7 @@
@include media-breakpoint-up(xl) { @include media-breakpoint-up(xl) {
background: none; background: none;
margin-left: 0.8rem !important;
} }
.icon { .icon {
@ -611,10 +641,14 @@
} }
@include media-breakpoint-up(xl) { @include media-breakpoint-up(xl) {
margin-left: 0.5em !important; margin-left: 3rem !important;
margin-right: 0.5em; margin-right: 0;
width: auto; width: auto;
&:last-child {
margin-right: 0;
}
.icon { .icon {
display: none !important; display: none !important;
} }
@ -629,6 +663,37 @@
} }
} }
a:link,
a:visited,
button {
align-items: center;
display: flex;
justify-content: center;
@include media-breakpoint-up(xl) {
border-radius: 2rem;
box-shadow: inset 0 0 0 2px #000;
padding: 0 2rem;
}
&:hover {
background-color: var(--link-hover-background);
&,
.textLabel {
color: #fff !important;
}
.icon {
display: none;
}
.iconHover {
display: block;
}
}
}
button { button {
margin: 0 !important; margin: 0 !important;
} }
@ -636,27 +701,6 @@
a::before { a::before {
display: none; display: none;
} }
a:hover,
button:hover {
.icon {
display: none;
}
.iconHover {
display: block;
}
.textLabel {
color: var(--link-hover-color);
}
}
a:hover {
.textLabel {
background-color: var(--link-hover-background);
}
}
} }
.subnavigation { .subnavigation {
@ -746,3 +790,65 @@
position: relative; position: relative;
top: 0.15em; top: 0.15em;
} }
.editorPopup {
border: 1px solid rgb(0 0 0 / 15%) !important;
border-radius: 1.6rem;
line-height: 1.3;
min-width: 28rem;
padding: 1.6rem !important;
}
.editorModePopupOpener {
display: inline-block;
margin-right: 2rem;
position: relative;
text-align: right;
width: 9em;
}
.editorModePopupOpenerIcon {
height: 2rem;
left: 100%;
margin-left: 0.2em;
top: 0;
transform: rotate(90deg);
position: absolute;
width: 2rem;
}
.editorModesList {
li {
cursor: pointer;
margin-bottom: 1.6rem;
padding-left: 3rem !important;
position: relative;
&:hover {
opacity: 0.6;
}
}
.editorModesSelected {
cursor: default;
opacity: 0.6;
}
}
.editorModeTitle {
color: #000;
margin-bottom: 0.5rem;
}
.editorModeDescription {
color: #696969;
font-size: 1.2rem;
}
.editorModeIcon {
height: 2.4rem;
left: 0;
position: absolute;
top: -0.2em;
width: 2.4rem;
}

View File

@ -14,10 +14,9 @@ import { Icon } from '../_shared/Icon'
import { Popover } from '../_shared/Popover' import { Popover } from '../_shared/Popover'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient' import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { ProfilePopup } from './ProfilePopup' import { Popup } from '../_shared/Popup'
import { useSnackbar } from '../../context/snackbar'
import styles from './Header/Header.module.scss' import styles from './Header/Header.module.scss'
import { ProfilePopup } from './ProfilePopup'
type Props = { type Props = {
setIsProfilePopupVisible: (value: boolean) => void setIsProfilePopupVisible: (value: boolean) => void
@ -51,7 +50,7 @@ export const HeaderAuth = (props: Props) => {
const isEditorPage = createMemo(() => page().route === 'edit' || page().route === 'editSettings') const isEditorPage = createMemo(() => page().route === 'edit' || page().route === 'editSettings')
const isNotificationsVisible = createMemo(() => isAuthenticated() && !isEditorPage()) const isNotificationsVisible = createMemo(() => isAuthenticated() && !isEditorPage())
const isSaveButtonVisible = createMemo(() => isAuthenticated() && isEditorPage()) const isSaveButtonVisible = createMemo(() => isAuthenticated() && isEditorPage())
const isCreatePostButtonVisible = createMemo(() => isAuthenticated() && !isEditorPage()) const isCreatePostButtonVisible = createMemo(() => !isEditorPage())
const isAuthenticatedControlsVisible = createMemo( const isAuthenticatedControlsVisible = createMemo(
() => isAuthenticated() && session()?.user?.email_verified, () => isAuthenticated() && session()?.user?.email_verified,
) )
@ -65,6 +64,7 @@ export const HeaderAuth = (props: Props) => {
} }
const [width, setWidth] = createSignal(0) const [width, setWidth] = createSignal(0)
const [editorMode, setEditorMode] = createSignal(t('Editing'))
onMount(() => { onMount(() => {
const handleResize = () => setWidth(window.innerWidth) const handleResize = () => setWidth(window.innerWidth)
@ -106,7 +106,7 @@ export const HeaderAuth = (props: Props) => {
<Show when={isSessionLoaded()} keyed={true}> <Show when={isSessionLoaded()} keyed={true}>
<div class={clsx('col-auto col-lg-7', styles.usernav)}> <div class={clsx('col-auto col-lg-7', styles.usernav)}>
<div class={styles.userControl}> <div class={styles.userControl}>
<Show when={isCreatePostButtonVisible()}> <Show when={isCreatePostButtonVisible() && isAuthenticated()}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}> <div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
<a href={getPagePath(router, 'create')}> <a href={getPagePath(router, 'create')}>
<span class={styles.textLabel}>{t('Create post')}</span> <span class={styles.textLabel}>{t('Create post')}</span>
@ -117,7 +117,7 @@ export const HeaderAuth = (props: Props) => {
</Show> </Show>
<Show when={!isSaveButtonVisible()}> <Show when={!isSaveButtonVisible()}>
<div class={styles.userControlItem}> <div class={clsx(styles.userControlItem, styles.userControlItemSearch)}>
<a href="?m=search"> <a href="?m=search">
<Icon name="search" class={styles.icon} /> <Icon name="search" class={styles.icon} />
<Icon name="search" class={clsx(styles.icon, styles.iconHover)} /> <Icon name="search" class={clsx(styles.icon, styles.iconHover)} />
@ -143,13 +143,47 @@ export const HeaderAuth = (props: Props) => {
</Show> </Show>
<Show when={isSaveButtonVisible()}> <Show when={isSaveButtonVisible()}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}> <Popup
{renderIconedButton({ trigger={
value: t('Save'), <span class={styles.editorModePopupOpener}>
icon: 'save', <Icon name="swiper-r-arr" class={styles.editorModePopupOpenerIcon} />
action: handleSaveButtonClick, {editorMode()}
})} </span>
</div> }
variant="bordered"
popupCssClass={styles.editorPopup}
>
<ul class={clsx('nodash', styles.editorModesList)}>
<li
class={clsx({ [styles.editorModesSelected]: editorMode() === t('Preview') })}
onClick={() => setEditorMode(t('Preview'))}
>
<Icon name="eye" class={styles.editorModeIcon} />
<div class={styles.editorModeTitle}>{t('Preview')}</div>
<div class={styles.editorModeDescription}>
Посмотрите, как материал будет выглядеть при публикации
</div>
</li>
<li
class={clsx({ [styles.editorModesSelected]: editorMode() === t('Editing') })}
onClick={() => setEditorMode(t('Editing'))}
>
<Icon name="pencil-outline" class={styles.editorModeIcon} />
<div class={styles.editorModeTitle}>{t('Editing')}</div>
<div class={styles.editorModeDescription}>Изменяйте текст напрямую в редакторе</div>
</li>
<li
class={clsx({ [styles.editorModesSelected]: editorMode() === t('Commenting') })}
onClick={() => setEditorMode(t('Commenting'))}
>
<Icon name="comment" class={styles.editorModeIcon} />
<div class={styles.editorModeTitle}>{t('Commenting')}</div>
<div class={styles.editorModeDescription}>
Предлагайте правки и комментарии, чтобы сделать материал лучше
</div>
</li>
</ul>
</Popup>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}> <div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
{renderIconedButton({ {renderIconedButton({
@ -159,12 +193,18 @@ export const HeaderAuth = (props: Props) => {
})} })}
</div> </div>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}> <div
class={clsx(
styles.userControlItem,
styles.settingsControlContainer,
styles.userControlItemVerbose,
)}
>
<Popover content={t('Settings')}> <Popover content={t('Settings')}>
{(ref) => ( {(ref) => (
<Button <Button
ref={ref} ref={ref}
value={<Icon name="burger" />} value={<Icon name="ellipsis" />}
variant={'light'} variant={'light'}
onClick={handleBurgerButtonClick} onClick={handleBurgerButtonClick}
class={styles.settingsControl} class={styles.settingsControl}
@ -173,16 +213,29 @@ export const HeaderAuth = (props: Props) => {
</Popover> </Popover>
</div> </div>
</Show> </Show>
<Show when={isCreatePostButtonVisible() && !isAuthenticated()}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
<a href={getPagePath(router, 'create')}>
<span class={styles.textLabel}>{t('Create post')}</span>
<Icon name="pencil" class={styles.icon} />
<Icon name="pencil" class={clsx(styles.icon, styles.iconHover)} />
</a>
</div>
</Show>
<Show <Show
when={isAuthenticatedControlsVisible()} when={isAuthenticatedControlsVisible()}
fallback={ fallback={
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}> <Show when={!isAuthenticated()}>
<a href="?m=auth&mode=login"> <div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}>
<span class={styles.textLabel}>{t('Enter')}</span> <a href="?m=auth&mode=login">
<Icon name="key" class={styles.icon} /> <span class={styles.textLabel}>{t('Enter')}</span>
{/*<Icon name="user-default" class={clsx(styles.icon, styles.iconHover)} />*/} <Icon name="key" class={styles.icon} />
</a> {/*<Icon name="user-default" class={clsx(styles.icon, styles.iconHover)} />*/}
</div> </a>
</div>
</Show>
} }
> >
<Show when={!isSaveButtonVisible()}> <Show when={!isSaveButtonVisible()}>
@ -195,28 +248,31 @@ export const HeaderAuth = (props: Props) => {
</a> </a>
</div> </div>
</Show> </Show>
<ProfilePopup
onVisibilityChange={(isVisible) => {
props.setIsProfilePopupVisible(isVisible)
}}
containerCssClass={styles.control}
trigger={
<div class={styles.userControlItem}>
<button class={styles.button}>
<div classList={{ entered: page().path === `/${author()?.slug}` }}>
<Userpic
size={'M'}
name={author()?.name}
userpic={author()?.pic}
class={styles.userpic}
/>
</div>
</button>
</div>
}
/>
</Show> </Show>
</div> </div>
<Show when={isAuthenticated()}>
<ProfilePopup
onVisibilityChange={(isVisible) => {
props.setIsProfilePopupVisible(isVisible)
}}
containerCssClass={styles.control}
trigger={
<div class={clsx(styles.userControlItem, styles.userControlItemUserpic)}>
<button class={styles.button}>
<div classList={{ entered: page().path === `/${author()?.slug}` }}>
<Userpic
size={'L'}
name={author()?.name}
userpic={author()?.pic}
class={styles.userpic}
/>
</div>
</button>
</div>
}
/>
</Show>
</div> </div>
</Show> </Show>
</ShowOnlyOnClient> </ShowOnlyOnClient>

View File

@ -1,5 +1,4 @@
.snackbar { .snackbar {
min-height: 2px;
background-color: var(--default-color); background-color: var(--default-color);
color: #fff; color: #fff;
font-size: 2rem; font-size: 2rem;

View File

@ -1,7 +1,18 @@
import { createFileUploader } from '@solid-primitives/upload' import { createFileUploader } from '@solid-primitives/upload'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import deepEqual from 'fast-deep-equal' import deepEqual from 'fast-deep-equal'
import { For, Match, Show, Switch, createEffect, createSignal, lazy, onCleanup, onMount } from 'solid-js' import {
For,
Match,
Show,
Switch,
createEffect,
createSignal,
lazy,
on,
onCleanup,
onMount,
} from 'solid-js'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { useConfirm } from '../../context/confirm' import { useConfirm } from '../../context/confirm'
@ -33,6 +44,7 @@ export const ProfileSettings = () => {
const { t } = useLocalize() const { t } = useLocalize()
const [prevForm, setPrevForm] = createStore({}) const [prevForm, setPrevForm] = createStore({})
const [isFormInitialized, setIsFormInitialized] = createSignal(false) const [isFormInitialized, setIsFormInitialized] = createSignal(false)
const [isSaving, setIsSaving] = createSignal(false)
const [social, setSocial] = createSignal([]) const [social, setSocial] = createSignal([])
const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false) const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false)
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false) const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
@ -70,16 +82,20 @@ export const ProfileSettings = () => {
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
setIsSaving(true)
if (nameInputRef.current.value.length === 0) { if (nameInputRef.current.value.length === 0) {
setNameError(t('Required')) setNameError(t('Required'))
nameInputRef.current.focus() nameInputRef.current.focus()
setIsSaving(false)
return return
} }
if (slugInputRef.current.value.length === 0) { if (slugInputRef.current.value.length === 0) {
setSlugError(t('Required')) setSlugError(t('Required'))
slugInputRef.current.focus() slugInputRef.current.focus()
setIsSaving(false)
return return
} }
try { try {
await submit(form) await submit(form)
setPrevForm(clone(form)) setPrevForm(clone(form))
@ -91,6 +107,8 @@ export const ProfileSettings = () => {
return return
} }
showSnackbar({ type: 'error', body: t('Error') }) showSnackbar({ type: 'error', body: t('Error') })
} finally {
setIsSaving(false)
} }
await loadAuthor() // renews author's profile await loadAuthor() // renews author's profile
@ -149,12 +167,15 @@ export const ProfileSettings = () => {
onCleanup(() => window.removeEventListener('beforeunload', handleBeforeUnload)) onCleanup(() => window.removeEventListener('beforeunload', handleBeforeUnload))
}) })
createEffect(() => { createEffect(
if (!deepEqual(form, prevForm)) { on(
setIsFloatingPanelVisible(true) () => deepEqual(form, prevForm),
} () => {
}) setIsFloatingPanelVisible(!deepEqual(form, prevForm))
},
{ defer: true },
),
)
const handleDeleteSocialLink = (link) => { const handleDeleteSocialLink = (link) => {
updateFormField('links', link, true) updateFormField('links', link, true)
} }
@ -174,7 +195,7 @@ export const ProfileSettings = () => {
<div class="col-md-20 col-lg-18 col-xl-16"> <div class="col-md-20 col-lg-18 col-xl-16">
<h1>{t('Profile settings')}</h1> <h1>{t('Profile settings')}</h1>
<p class="description">{t('Here you can customize your profile the way you want.')}</p> <p class="description">{t('Here you can customize your profile the way you want.')}</p>
<form enctype="multipart/form-data"> <form enctype="multipart/form-data" autocomplete="off">
<h4>{t('Userpic')}</h4> <h4>{t('Userpic')}</h4>
<div class="pretty-form__item"> <div class="pretty-form__item">
<div <div
@ -241,14 +262,16 @@ export const ProfileSettings = () => {
<div class="pretty-form__item"> <div class="pretty-form__item">
<input <input
type="text" type="text"
name="username" name="nameOfUser"
id="username" id="nameOfUser"
data-lpignore="true"
autocomplete="one-time-code"
placeholder={t('Name')} placeholder={t('Name')}
onInput={(event) => updateFormField('name', event.currentTarget.value)} onInput={(event) => updateFormField('name', event.currentTarget.value)}
value={form.name} value={form.name}
ref={(el) => (nameInputRef.current = el)} ref={(el) => (nameInputRef.current = el)}
/> />
<label for="username">{t('Name')}</label> <label for="nameOfUser">{t('Name')}</label>
<Show when={nameError()}> <Show when={nameError()}>
<div <div
style={{ position: 'absolute', 'margin-top': '-4px' }} style={{ position: 'absolute', 'margin-top': '-4px' }}
@ -268,6 +291,8 @@ export const ProfileSettings = () => {
type="text" type="text"
name="user-address" name="user-address"
id="user-address" id="user-address"
data-lpignore="true"
autocomplete="one-time-code2"
onInput={(event) => updateFormField('slug', event.currentTarget.value)} onInput={(event) => updateFormField('slug', event.currentTarget.value)}
value={form.slug} value={form.slug}
ref={(el) => (slugInputRef.current = el)} ref={(el) => (slugInputRef.current = el)}
@ -359,7 +384,12 @@ export const ProfileSettings = () => {
} }
onClick={handleCancel} onClick={handleCancel}
/> />
<Button onClick={handleSubmit} variant="primary" value={t('Save settings')} /> <Button
onClick={handleSubmit}
variant="primary"
disabled={isSaving()}
value={isSaving() ? t('Saving...') : t('Save settings')}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -17,7 +17,7 @@ interface Props {
const isInViewport = (el: Element): boolean => { const isInViewport = (el: Element): boolean => {
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
return rect.top <= DEFAULT_HEADER_OFFSET return rect.top <= DEFAULT_HEADER_OFFSET + 24 // default offset + 1.5em (default header margin-top)
} }
const scrollToHeader = (element) => { const scrollToHeader = (element) => {
window.scrollTo({ window.scrollTo({

View File

@ -40,7 +40,7 @@ export const FullTopic = (props: Props) => {
return ( return (
<div class={clsx(styles.topicHeader, 'col-md-16 col-lg-12 offset-md-4 offset-lg-6')}> <div class={clsx(styles.topicHeader, 'col-md-16 col-lg-12 offset-md-4 offset-lg-6')}>
<h1>#{props.topic?.title}</h1> <h1>#{props.topic?.title}</h1>
<p>{props.topic?.body}</p> <p innerHTML={props.topic?.body} />
<div class={clsx(styles.topicActions)}> <div class={clsx(styles.topicActions)}>
<Button <Button
variant="primary" variant="primary"

View File

@ -72,7 +72,7 @@ export const TopicBadge = (props: Props) => {
</div> </div>
} }
> >
<div class={clsx('text-truncate', styles.description)}>{props.topic.body}</div> <div innerHTML={props.topic.body} class={clsx('text-truncate', styles.description)} />
</Show> </Show>
</a> </a>
</div> </div>

View File

@ -7,6 +7,7 @@ import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on, o
import { useFollowing } from '../../../context/following' import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
import { apiClient } from '../../../graphql/client/core' import { apiClient } from '../../../graphql/client/core'
import { router, useRouter } from '../../../stores/router' import { router, useRouter } from '../../../stores/router'
import { loadShouts, useArticlesStore } from '../../../stores/zine/articles' import { loadShouts, useArticlesStore } from '../../../stores/zine/articles'
@ -29,41 +30,45 @@ import stylesArticle from '../../Article/Article.module.scss'
import styles from './Author.module.scss' import styles from './Author.module.scss'
type Props = { type Props = {
shouts: Shout[]
author: Author
authorSlug: string authorSlug: string
shouts?: Shout[]
author?: Author
} }
export const PRERENDERED_ARTICLES_COUNT = 12 export const PRERENDERED_ARTICLES_COUNT = 12
const LOAD_MORE_PAGE_SIZE = 9 const LOAD_MORE_PAGE_SIZE = 9
export const AuthorView = (props: Props) => { export const AuthorView = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { subscriptions, followers: myFollowers, loadSubscriptions } = useFollowing()
const { session } = useSession()
const { sortedArticles } = useArticlesStore({ shouts: props.shouts }) const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
const { authorEntities } = useAuthorsStore({ authors: [props.author] }) const { authorEntities } = useAuthorsStore({ authors: [props.author] })
const { page: getPage, searchParams } = useRouter() const { page: getPage, searchParams } = useRouter()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [isBioExpanded, setIsBioExpanded] = createSignal(false) const [isBioExpanded, setIsBioExpanded] = createSignal(false)
const [followers, setFollowers] = createSignal<Author[]>([]) const [author, setAuthor] = createSignal<Author>()
const [following, setFollowing] = createSignal<Array<Author | Topic>>([]) const [followers, setFollowers] = createSignal([])
const [following, setFollowing] = createSignal<Array<Author | Topic>>([]) // flat AuthorFollowsResult
const [showExpandBioControl, setShowExpandBioControl] = createSignal(false) const [showExpandBioControl, setShowExpandBioControl] = createSignal(false)
const [commented, setCommented] = createSignal<Reaction[]>() const [commented, setCommented] = createSignal<Reaction[]>()
const modal = MODALS[searchParams().m] const modal = MODALS[searchParams().m]
// current author const [sessionChecked, setSessionChecked] = createSignal(false)
const [author, setAuthor] = createSignal<Author>()
createEffect(() => { createEffect(() => {
try { if (
const a = authorEntities()[props.authorSlug] !sessionChecked() &&
setAuthor(a) props.authorSlug &&
} catch (error) { session()?.user?.app_data?.profile?.slug === props.authorSlug
console.debug(error) ) {
} setSessionChecked(true)
}) const appdata = session()?.user.app_data
if (appdata) {
createEffect(async () => { console.info('preloaded my own profile')
if (author()?.id && !author().stat) { const { authors, profile, topics } = appdata
const a = await loadAuthor({ slug: '', author_id: author().id }) setFollowers(myFollowers)
console.debug('[AuthorView] loaded author:', a) setAuthor(profile)
setFollowing([...authors, ...topics])
}
} }
}) })
@ -72,15 +77,17 @@ export const AuthorView = (props: Props) => {
const fetchData = async (author: Author) => { const fetchData = async (author: Author) => {
try { try {
const [subscriptionsResult, followersResult] = await Promise.all([ const [subscriptionsResult, followersResult, authorResult] = await Promise.all([
apiClient.getAuthorFollows({ author_id: author.id }), apiClient.getAuthorFollows({ slug }),
apiClient.getAuthorFollowers({ slug: author.slug }), apiClient.getAuthorFollowers({ slug }),
loadAuthor({ slug }),
]) ])
const { authors, topics } = subscriptionsResult const { authors, topics } = subscriptionsResult
setAuthor(authorResult)
setFollowing([...(authors || []), ...(topics || [])]) setFollowing([...(authors || []), ...(topics || [])])
setFollowers(followersResult || []) setFollowers(followersResult || [])
console.info('[components.Author] following data loaded') console.info('[components.Author] data loaded')
} catch (error) { } catch (error) {
console.error('[components.Author] fetch error', error) console.error('[components.Author] fetch error', error)
} }
@ -104,10 +111,10 @@ export const AuthorView = (props: Props) => {
} }
onMount(() => { onMount(() => {
if (!modal) { if (!modal) hideModal()
hideModal()
}
checkBioHeight() checkBioHeight()
fetchData(props.authorSlug)
// pagination // pagination
if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) { if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) {
loadMore() loadMore()
@ -120,7 +127,7 @@ export const AuthorView = (props: Props) => {
const fetchComments = async (commenter: Author) => { const fetchComments = async (commenter: Author) => {
const data = await apiClient.getReactionsBy({ const data = await apiClient.getReactionsBy({
by: { comment: false, created_by: commenter.id }, by: { comment: true, created_by: commenter.id },
}) })
setCommented(data) setCommented(data)
} }
@ -160,31 +167,53 @@ export const AuthorView = (props: Props) => {
<Show when={author()} fallback={<Loading />}> <Show when={author()} fallback={<Loading />}>
<> <>
<div class={styles.authorHeader}> <div class={styles.authorHeader}>
<AuthorCard author={author()} followers={followers()} following={following()} /> <AuthorCard author={author()} followers={followers() || []} following={following() || []} />
</div> </div>
<div class={clsx(styles.groupControls, 'row')}> <div class={clsx(styles.groupControls, 'row')}>
<div class="col-md-16"> <div class="col-md-16">
<ul class="view-switcher"> <ul class="view-switcher">
<li classList={{ 'view-switcher__item--selected': getPage().route === 'author' }}> <li
<a href={getPagePath(router, 'author', { slug: props.authorSlug })}> classList={{
'view-switcher__item--selected': getPage().route === 'author',
}}
>
<a
href={getPagePath(router, 'author', {
slug: props.authorSlug,
})}
>
{t('Publications')} {t('Publications')}
</a> </a>
<Show when={author().stat}> <Show when={author().stat}>
<span class="view-switcher__counter">{author().stat.shouts}</span> <span class="view-switcher__counter">{author().stat.shouts}</span>
</Show> </Show>
</li> </li>
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorComments' }}> <li
<a href={getPagePath(router, 'authorComments', { slug: props.authorSlug })}> classList={{
'view-switcher__item--selected': getPage().route === 'authorComments',
}}
>
<a
href={getPagePath(router, 'authorComments', {
slug: props.authorSlug,
})}
>
{t('Comments')} {t('Comments')}
</a> </a>
<Show when={author().stat}> <Show when={author().stat}>
<span class="view-switcher__counter">{author().stat.comments}</span> <span class="view-switcher__counter">{author().stat.comments}</span>
</Show> </Show>
</li> </li>
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorAbout' }}> <li
classList={{
'view-switcher__item--selected': getPage().route === 'authorAbout',
}}
>
<a <a
onClick={() => checkBioHeight()} onClick={() => checkBioHeight()}
href={getPagePath(router, 'authorAbout', { slug: props.authorSlug })} href={getPagePath(router, 'authorAbout', {
slug: props.authorSlug,
})}
> >
{t('Profile')} {t('Profile')}
</a> </a>

View File

@ -24,34 +24,31 @@ type Props = {
layout: LayoutType layout: LayoutType
} }
export const PRERENDERED_ARTICLES_COUNT = 37 export const PRERENDERED_ARTICLES_COUNT = 36
const LOAD_MORE_PAGE_SIZE = 11 const LOAD_MORE_PAGE_SIZE = 12
export const Expo = (props: Props) => { export const Expo = (props: Props) => {
const [isLoaded, setIsLoaded] = createSignal<boolean>(Boolean(props.shouts)) const [isLoaded, setIsLoaded] = createSignal<boolean>(Boolean(props.shouts))
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [randomTopArticles, setRandomTopArticles] = createSignal<Shout[]>([]) const [favoriteTopArticles, setFavoriteTopArticles] = createSignal<Shout[]>([])
const [randomTopMonthArticles, setRandomTopMonthArticles] = createSignal<Shout[]>([]) const [reactedTopMonthArticles, setReactedTopMonthArticles] = createSignal<Shout[]>([])
const { t } = useLocalize() const { t } = useLocalize()
// const { sortedArticles } = useArticlesStore({
// shouts: isLoaded() ? props.shouts : [],
// })
const { sortedArticles } = useArticlesStore({ const { sortedArticles } = useArticlesStore({
shouts: props.shouts || [], shouts: isLoaded() ? props.shouts : [],
layout: props.layout, layout: props.layout,
}) })
const getLoadShoutsFilters = (additionalFilters: LoadShoutsFilters = {}): LoadShoutsFilters => { const getLoadShoutsFilters = (additionalFilters: LoadShoutsFilters = {}): LoadShoutsFilters => {
const filters = { featured: true, ...additionalFilters } const filters = { ...additionalFilters }
if (!filters.layouts) filters.layouts = [] if (!filters.layouts) filters.layouts = []
if (props.layout) { if (props.layout) {
filters.layouts.push(props.layout) filters.layouts.push(props.layout)
} else { } else {
filters.layouts.push('article') filters.layouts.push('audio', 'video', 'image', 'literature')
} }
return filters return filters
@ -80,13 +77,12 @@ export const Expo = (props: Props) => {
const loadRandomTopArticles = async () => { const loadRandomTopArticles = async () => {
const options: LoadShoutsOptions = { const options: LoadShoutsOptions = {
filters: getLoadShoutsFilters(), filters: { ...getLoadShoutsFilters(), featured: true },
limit: 10, limit: 10,
random_limit: 100, random_limit: 100,
} }
const result = await apiClient.getRandomTopShouts({ options }) const result = await apiClient.getRandomTopShouts({ options })
setRandomTopArticles(result) setFavoriteTopArticles(result)
} }
const loadRandomTopMonthArticles = async () => { const loadRandomTopMonthArticles = async () => {
@ -94,19 +90,15 @@ export const Expo = (props: Props) => {
const after = getUnixtime(new Date(now.setMonth(now.getMonth() - 1))) const after = getUnixtime(new Date(now.setMonth(now.getMonth() - 1)))
const options: LoadShoutsOptions = { const options: LoadShoutsOptions = {
filters: getLoadShoutsFilters({ after }), filters: { ...getLoadShoutsFilters({ after }), reacted: true },
limit: 10, limit: 10,
random_limit: 10, random_limit: 10,
} }
const result = await apiClient.getRandomTopShouts({ options }) const result = await apiClient.getRandomTopShouts({ options })
setRandomTopMonthArticles(result) setReactedTopMonthArticles(result)
} }
const pages = createMemo<Shout[][]>(() =>
splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE),
)
onMount(() => { onMount(() => {
if (isLoaded()) { if (isLoaded()) {
return return
@ -130,8 +122,8 @@ export const Expo = (props: Props) => {
() => props.layout, () => props.layout,
() => { () => {
resetSortedArticles() resetSortedArticles()
setRandomTopArticles([]) setFavoriteTopArticles([])
setRandomTopMonthArticles([]) setReactedTopMonthArticles([])
loadMore(PRERENDERED_ARTICLES_COUNT + LOAD_MORE_PAGE_SIZE) loadMore(PRERENDERED_ARTICLES_COUNT + LOAD_MORE_PAGE_SIZE)
loadRandomTopArticles() loadRandomTopArticles()
loadRandomTopMonthArticles() loadRandomTopMonthArticles()
@ -202,7 +194,7 @@ export const Expo = (props: Props) => {
</li> </li>
</ul> </ul>
<div class="row"> <div class="row">
<For each={sortedArticles().slice(0, PRERENDERED_ARTICLES_COUNT / 2)}> <For each={sortedArticles().slice(0, LOAD_MORE_PAGE_SIZE)}>
{(shout) => ( {(shout) => (
<div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3"> <div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
<ArticleCard <ArticleCard
@ -214,10 +206,10 @@ export const Expo = (props: Props) => {
</div> </div>
)} )}
</For> </For>
<Show when={randomTopMonthArticles()?.length > 0} keyed={true}> <Show when={reactedTopMonthArticles()?.length > 0} keyed={true}>
<ArticleCardSwiper title={t('Top month articles')} slides={randomTopMonthArticles()} /> <ArticleCardSwiper title={t('Top month articles')} slides={reactedTopMonthArticles()} />
</Show> </Show>
<For each={sortedArticles().slice(PRERENDERED_ARTICLES_COUNT / 2, PRERENDERED_ARTICLES_COUNT)}> <For each={sortedArticles().slice(LOAD_MORE_PAGE_SIZE, LOAD_MORE_PAGE_SIZE * 2)}>
{(shout) => ( {(shout) => (
<div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3"> <div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
<ArticleCard <ArticleCard
@ -229,23 +221,19 @@ export const Expo = (props: Props) => {
</div> </div>
)} )}
</For> </For>
<Show when={randomTopArticles()?.length > 0} keyed={true}> <Show when={favoriteTopArticles()?.length > 0} keyed={true}>
<ArticleCardSwiper title={t('Favorite')} slides={randomTopArticles()} /> <ArticleCardSwiper title={t('Favorite')} slides={favoriteTopArticles()} />
</Show> </Show>
<For each={pages()}> <For each={sortedArticles().slice(LOAD_MORE_PAGE_SIZE * 2)}>
{(page) => ( {(shout) => (
<For each={page}> <div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
{(shout) => ( <ArticleCard
<div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3"> article={shout}
<ArticleCard settings={{ nodate: true, nosubtitle: true, noAuthorLink: true }}
article={shout} desktopCoverSize="XS"
settings={{ nodate: true, nosubtitle: true, noAuthorLink: true }} withAspectRatio={true}
desktopCoverSize="XS" />
withAspectRatio={true} </div>
/>
</div>
)}
</For>
)} )}
</For> </For>
</div> </div>

View File

@ -54,6 +54,13 @@ type FeedSearchParams = {
visibility: VisibilityMode visibility: VisibilityMode
} }
type Props = {
loadShouts: (options: LoadShoutsOptions) => Promise<{
hasMore: boolean
newShouts: Shout[]
}>
}
const getFromDate = (period: FeedPeriod): number => { const getFromDate = (period: FeedPeriod): number => {
const now = new Date() const now = new Date()
let d: Date = now let d: Date = now
@ -74,18 +81,10 @@ const getFromDate = (period: FeedPeriod): number => {
return Math.floor(d.getTime() / 1000) return Math.floor(d.getTime() / 1000)
} }
type Props = {
loadShouts: (options: LoadShoutsOptions) => Promise<{
hasMore: boolean
newShouts: Shout[]
}>
}
export const FeedView = (props: Props) => { export const FeedView = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const monthPeriod: PeriodItem = { value: 'month', title: t('This month') } const monthPeriod: PeriodItem = { value: 'month', title: t('This month') }
const visibilityAll = { value: 'featured', title: t('All') }
const periods: PeriodItem[] = [ const periods: PeriodItem[] = [
{ value: 'week', title: t('This week') }, { value: 'week', title: t('This week') },
@ -121,7 +120,7 @@ export const FeedView = (props: Props) => {
const currentVisibility = createMemo(() => { const currentVisibility = createMemo(() => {
const visibility = visibilities.find((v) => v.value === searchParams().visibility) const visibility = visibilities.find((v) => v.value === searchParams().visibility)
if (!visibility) { if (!visibility) {
return visibilityAll return visibilities[0]
} }
return visibility return visibility
}) })
@ -172,6 +171,7 @@ export const FeedView = (props: Props) => {
} }
const visibilityMode = searchParams().visibility const visibilityMode = searchParams().visibility
if (visibilityMode === 'all') { if (visibilityMode === 'all') {
options.filters = { ...options.filters } options.filters = { ...options.filters }
} else if (visibilityMode) { } else if (visibilityMode) {
@ -185,6 +185,7 @@ export const FeedView = (props: Props) => {
const period = searchParams().period || 'month' const period = searchParams().period || 'month'
options.filters = { after: getFromDate(period) } options.filters = { after: getFromDate(period) }
} }
return props.loadShouts(options) return props.loadShouts(options)
} }

View File

@ -1,6 +1,7 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, createEffect, createSignal, onMount } from 'solid-js' import { For, Show, createEffect, createSignal, onMount } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { apiClient } from '../../../graphql/client/core' import { apiClient } from '../../../graphql/client/core'
@ -20,41 +21,32 @@ import stylesSettings from '../../../styles/FeedSettings.module.scss'
export const ProfileSubscriptions = () => { export const ProfileSubscriptions = () => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const { author } = useSession() const { author, session } = useSession()
const { subscriptions } = useFollowing()
const [following, setFollowing] = createSignal<Array<Author | Topic>>([]) const [following, setFollowing] = createSignal<Array<Author | Topic>>([])
const [filtered, setFiltered] = createSignal<Array<Author | Topic>>([]) const [filtered, setFiltered] = createSignal<Array<Author | Topic>>([])
const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all') const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all')
const [searchQuery, setSearchQuery] = createSignal('') const [searchQuery, setSearchQuery] = createSignal('')
const fetchSubscriptions = async () => {
try {
const slug = author()?.slug
const authorFollows = await apiClient.getAuthorFollows({ slug })
setFollowing([...authorFollows['authors']])
setFiltered([...authorFollows['authors'], ...authorFollows['topics']])
} catch (error) {
console.error('[fetchSubscriptions] :', error)
throw error
}
}
createEffect(() => { createEffect(() => {
if (following()) { const { authors, topics } = subscriptions
if (authors || topics) {
const fdata = [...(authors || []), ...(topics || [])]
setFollowing(fdata)
if (subscriptionFilter() === 'authors') { if (subscriptionFilter() === 'authors') {
setFiltered(following().filter((s) => 'name' in s)) setFiltered(fdata.filter((s) => 'name' in s))
} else if (subscriptionFilter() === 'topics') { } else if (subscriptionFilter() === 'topics') {
setFiltered(following().filter((s) => 'title' in s)) setFiltered(fdata.filter((s) => 'title' in s))
} else { } else {
setFiltered(following()) setFiltered(fdata)
} }
} }
if (searchQuery()) {
setFiltered(dummyFilter(following(), searchQuery(), lang()))
}
}) })
onMount(async () => { createEffect(() => {
await fetchSubscriptions() if (searchQuery()) {
setFiltered(dummyFilter(following(), searchQuery(), lang()))
}
}) })
return ( return (
@ -73,17 +65,29 @@ export const ProfileSubscriptions = () => {
<p class="description">{t('Here you can manage all your Discours subscriptions')}</p> <p class="description">{t('Here you can manage all your Discours subscriptions')}</p>
<Show when={following()} fallback={<Loading />}> <Show when={following()} fallback={<Loading />}>
<ul class="view-switcher"> <ul class="view-switcher">
<li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'all' })}> <li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'all',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('all')}> <button type="button" onClick={() => setSubscriptionFilter('all')}>
{t('All')} {t('All')}
</button> </button>
</li> </li>
<li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'authors' })}> <li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'authors',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('authors')}> <button type="button" onClick={() => setSubscriptionFilter('authors')}>
{t('Authors')} {t('Authors')}
</button> </button>
</li> </li>
<li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'topics' })}> <li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'topics',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('topics')}> <button type="button" onClick={() => setSubscriptionFilter('topics')}>
{t('Topics')} {t('Topics')}
</button> </button>

View File

@ -1,8 +1,8 @@
import type { Shout, Topic } from '../../graphql/schema/core.gen' import { LoadShoutsOptions, Shout, Topic } from '../../graphql/schema/core.gen'
import { Meta } from '@solidjs/meta' import { Meta } from '@solidjs/meta'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js' import { For, Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { useRouter } from '../../stores/router' import { useRouter } from '../../stores/router'
@ -21,7 +21,9 @@ import { Row3 } from '../Feed/Row3'
import { FullTopic } from '../Topic/Full' import { FullTopic } from '../Topic/Full'
import { ArticleCardSwiper } from '../_shared/SolidSwiper/ArticleCardSwiper' import { ArticleCardSwiper } from '../_shared/SolidSwiper/ArticleCardSwiper'
import { apiClient } from '../../graphql/client/core'
import styles from '../../styles/Topic.module.scss' import styles from '../../styles/Topic.module.scss'
import { getUnixtime } from '../../utils/getServerDate'
type TopicsPageSearchParams = { type TopicsPageSearchParams = {
by: 'comments' | '' | 'recent' | 'viewed' | 'rating' | 'commented' by: 'comments' | '' | 'recent' | 'viewed' | 'rating' | 'commented'
@ -43,14 +45,56 @@ export const TopicView = (props: Props) => {
const { sortedArticles } = useArticlesStore({ shouts: props.shouts }) const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
const { topicEntities } = useTopicsStore({ topics: [props.topic] }) const { topicEntities } = useTopicsStore({ topics: [props.topic] })
const { authorsByTopic } = useAuthorsStore() const { authorsByTopic } = useAuthorsStore()
const [favoriteTopArticles, setFavoriteTopArticles] = createSignal<Shout[]>([])
const [reactedTopMonthArticles, setReactedTopMonthArticles] = createSignal<Shout[]>([])
const [topic, setTopic] = createSignal<Topic>() const [topic, setTopic] = createSignal<Topic>()
createEffect(() => { createEffect(() => {
const topics = topicEntities() const topics = topicEntities()
if (props.topicSlug && !topic() && topics) { if (props.topicSlug && !topic() && topics) {
setTopic(topics[props.topicSlug]) setTopic(topics[props.topicSlug])
} }
}) })
const loadFavoriteTopArticles = async (topic: string) => {
const options: LoadShoutsOptions = {
filters: { featured: true, topic: topic },
limit: 10,
random_limit: 100,
}
const result = await apiClient.getRandomTopShouts({ options })
setFavoriteTopArticles(result)
}
const loadReactedTopMonthArticles = async (topic: string) => {
const now = new Date()
const after = getUnixtime(new Date(now.setMonth(now.getMonth() - 1)))
const options: LoadShoutsOptions = {
filters: { after: after, featured: true, topic: topic },
limit: 10,
random_limit: 10,
}
const result = await apiClient.getRandomTopShouts({ options })
setReactedTopMonthArticles(result)
}
const loadRandom = () => {
loadFavoriteTopArticles(topic()?.slug)
loadReactedTopMonthArticles(topic()?.slug)
}
createEffect(
on(
() => topic(),
() => loadRandom(),
{ defer: true },
),
)
const title = createMemo( const title = createMemo(
() => () =>
`#${capitalize( `#${capitalize(
@ -75,6 +119,7 @@ export const TopicView = (props: Props) => {
} }
onMount(() => { onMount(() => {
loadRandom()
if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) { if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) {
loadMore() loadMore()
} }
@ -170,9 +215,9 @@ export const TopicView = (props: Props) => {
beside={sortedArticles()[4]} beside={sortedArticles()[4]}
wrapper={'author'} wrapper={'author'}
/> />
<Show when={reactedTopMonthArticles()?.length > 0} keyed={true}>
<ArticleCardSwiper title={title()} slides={sortedArticles().slice(5, 11)} /> <ArticleCardSwiper title={t('Top month articles')} slides={reactedTopMonthArticles()} />
</Show>
<Beside <Beside
beside={sortedArticles()[12]} beside={sortedArticles()[12]}
title={t('Top viewed')} title={t('Top viewed')}
@ -183,8 +228,10 @@ export const TopicView = (props: Props) => {
<Row2 articles={sortedArticles().slice(13, 15)} isEqual={true} /> <Row2 articles={sortedArticles().slice(13, 15)} isEqual={true} />
<Row1 article={sortedArticles()[15]} /> <Row1 article={sortedArticles()[15]} />
<Show when={favoriteTopArticles()?.length > 0} keyed={true}>
<ArticleCardSwiper title={t('Favorite')} slides={favoriteTopArticles()} />
</Show>
<Show when={sortedArticles().length > 15}> <Show when={sortedArticles().length > 15}>
<ArticleCardSwiper slides={sortedArticles().slice(16, 22)} />
<Row3 articles={sortedArticles().slice(23, 26)} /> <Row3 articles={sortedArticles().slice(23, 26)} />
<Row2 articles={sortedArticles().slice(26, 28)} /> <Row2 articles={sortedArticles().slice(26, 28)} />
</Show> </Show>

View File

@ -10,18 +10,23 @@
} }
.notificationsCounter { .notificationsCounter {
background-color: #d00820; align-items: center;
border: 2px solid #fff; background-color: #E84500;
border-radius: 2em; border-radius: 0.8rem;
color: #fff; color: #fff;
font-size: 1rem; display: flex;
font-size: 1.2rem;
font-weight: 700; font-weight: 700;
height: 1.6em; height: 2.2rem;
left: 1.1em; justify-content: center;
line-height: 1.25em; left: 1.6rem;
min-width: 2.2rem;
padding: 0 0.25em; padding: 0 0.25em;
position: absolute; position: absolute;
text-align: center; text-align: center;
top: -0.5rem; top: -0.5rem;
min-width: 1.5em;
@include media-breakpoint-up(md) {
left: 1.8rem;
}
} }

View File

@ -1,7 +1,7 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js' import { For, Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
import SwiperCore from 'swiper' import SwiperCore from 'swiper'
import { Manipulation, Navigation, Pagination } from 'swiper/modules' import { HashNavigation, Manipulation, Navigation, Pagination } from 'swiper/modules'
import { throttle } from 'throttle-debounce' import { throttle } from 'throttle-debounce'
import { MediaItem } from '../../../pages/types' import { MediaItem } from '../../../pages/types'
@ -12,6 +12,8 @@ import { Lightbox } from '../Lightbox'
import { SwiperRef } from './swiper' import { SwiperRef } from './swiper'
import { useRouter } from '../../../stores/router'
import { ArticlePageSearchParams } from '../../Article/FullArticle'
import styles from './Swiper.module.scss' import styles from './Swiper.module.scss'
type Props = { type Props = {
@ -31,10 +33,13 @@ export const ImageSwiper = (props: Props) => {
const [slideIndex, setSlideIndex] = createSignal(0) const [slideIndex, setSlideIndex] = createSignal(0)
const [isMobileView, setIsMobileView] = createSignal(false) const [isMobileView, setIsMobileView] = createSignal(false)
const [selectedImage, setSelectedImage] = createSignal('') const [selectedImage, setSelectedImage] = createSignal('')
const { searchParams, changeSearchParams } = useRouter<ArticlePageSearchParams>()
const handleSlideChange = () => { const handleSlideChange = () => {
thumbSwipeRef.current.swiper.slideTo(mainSwipeRef.current.swiper.activeIndex) const activeIndex = mainSwipeRef.current.swiper.activeIndex
setSlideIndex(mainSwipeRef.current.swiper.activeIndex) thumbSwipeRef.current.swiper.slideTo(activeIndex)
setSlideIndex(activeIndex)
changeSearchParams({ slide: `${activeIndex + 1}` })
} }
createEffect( createEffect(
@ -51,8 +56,19 @@ export const ImageSwiper = (props: Props) => {
onMount(async () => { onMount(async () => {
const { register } = await import('swiper/element/bundle') const { register } = await import('swiper/element/bundle')
register() register()
SwiperCore.use([Pagination, Navigation, Manipulation]) SwiperCore.use([Pagination, Navigation, Manipulation, HashNavigation])
mainSwipeRef.current?.swiper?.on('slideChange', handleSlideChange) while (!mainSwipeRef.current || !mainSwipeRef.current.swiper) {
await new Promise((resolve) => setTimeout(resolve, 10)) // wait 10 ms
}
mainSwipeRef.current.swiper.on('slideChange', handleSlideChange)
const initialSlide = parseInt(searchParams().slide) - 1
if (initialSlide && !Number.isNaN(initialSlide) && initialSlide < props.images.length) {
mainSwipeRef.current.swiper.slideTo(initialSlide, 0)
} else {
changeSearchParams({ slide: '1' })
}
mainSwipeRef.current.swiper.init()
}) })
onMount(() => { onMount(() => {
@ -103,6 +119,9 @@ export const ImageSwiper = (props: Props) => {
watch-slides-visibility={true} watch-slides-visibility={true}
direction={'horizontal'} direction={'horizontal'}
slides-per-group-auto={true} slides-per-group-auto={true}
hash-navigation={{
watchState: true,
}}
> >
<For each={props.images}> <For each={props.images}>
{(slide, index) => ( {(slide, index) => (
@ -149,7 +168,7 @@ export const ImageSwiper = (props: Props) => {
{(slide, index) => ( {(slide, index) => (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
<swiper-slide lazy="true" virtual-index={index()}> <swiper-slide lazy="true" virtual-index={index()} data-hash={index() + 1}>
<div class={styles.image} onClick={handleImageClick}> <div class={styles.image} onClick={handleImageClick}>
<Image src={slide.url} alt={slide.title} width={800} /> <Image src={slide.url} alt={slide.title} width={800} />
</div> </div>

View File

@ -135,9 +135,13 @@
.counter { .counter {
@include font-size(1.2rem); @include font-size(1.2rem);
@include media-breakpoint-up(sm) {
top: 477px;
}
position: absolute; position: absolute;
z-index: 2; z-index: 2;
top: 477px; top: 276px;
right: 0; right: 0;
font-weight: 600; font-weight: 600;
padding: 0.2rem 0.8rem; padding: 0.2rem 0.8rem;

View File

@ -2,7 +2,7 @@ import { Accessor, JSX, createContext, createEffect, createMemo, createSignal, u
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { apiClient } from '../graphql/client/core' import { apiClient } from '../graphql/client/core'
import { Author, AuthorFollows, Community, FollowingEntity, Topic } from '../graphql/schema/core.gen' import { Author, AuthorFollowsResult, FollowingEntity } from '../graphql/schema/core.gen'
import { useSession } from './session' import { useSession } from './session'
@ -16,8 +16,9 @@ type SubscribeAction = { slug: string; type: 'subscribe' | 'unsubscribe' }
interface FollowingContextType { interface FollowingContextType {
loading: Accessor<boolean> loading: Accessor<boolean>
subscriptions: AuthorFollows followers: Accessor<Array<Author>>
setSubscriptions: (subscriptions: AuthorFollows) => void subscriptions: AuthorFollowsResult
setSubscriptions: (subscriptions: AuthorFollowsResult) => void
setFollowing: (what: FollowingEntity, slug: string, value: boolean) => void setFollowing: (what: FollowingEntity, slug: string, value: boolean) => void
loadSubscriptions: () => void loadSubscriptions: () => void
follow: (what: FollowingEntity, slug: string) => Promise<void> follow: (what: FollowingEntity, slug: string) => Promise<void>
@ -32,7 +33,7 @@ export function useFollowing() {
return useContext(FollowingContext) return useContext(FollowingContext)
} }
const EMPTY_SUBSCRIPTIONS: AuthorFollows = { const EMPTY_SUBSCRIPTIONS: AuthorFollowsResult = {
topics: [], topics: [],
authors: [], authors: [],
communities: [], communities: [],
@ -40,9 +41,9 @@ const EMPTY_SUBSCRIPTIONS: AuthorFollows = {
export const FollowingProvider = (props: { children: JSX.Element }) => { export const FollowingProvider = (props: { children: JSX.Element }) => {
const [loading, setLoading] = createSignal<boolean>(false) const [loading, setLoading] = createSignal<boolean>(false)
const [subscriptions, setSubscriptions] = createStore<SubscriptionsData>(EMPTY_SUBSCRIPTIONS) const [followers, setFollowers] = createSignal<Array<Author>>([])
const { session, author } = useSession() const [subscriptions, setSubscriptions] = createStore<AuthorFollowsResult>(EMPTY_SUBSCRIPTIONS)
const [subscribeInAction, setSubscribeInAction] = createSignal<SubscribeAction>() const { author, session } = useSession()
const fetchData = async () => { const fetchData = async () => {
setLoading(true) setLoading(true)
@ -94,8 +95,17 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
createEffect(() => { createEffect(() => {
if (author()) { if (author()) {
console.debug('[context.following] author update detect') try {
fetchData() const appdata = session()?.user.app_data
if (appdata) {
const { authors, followers, topics } = appdata
setSubscriptions({ authors, topics })
setFollowers(followers)
if (!authors) fetchData()
}
} catch (e) {
console.error(e)
}
} }
}) })
@ -125,6 +135,7 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
subscriptions, subscriptions,
setSubscriptions, setSubscriptions,
setFollowing, setFollowing,
followers,
loadSubscriptions: fetchData, loadSubscriptions: fetchData,
follow, follow,
unfollow, unfollow,

View File

@ -1,5 +1,5 @@
import type { Accessor, JSX, Resource } from 'solid-js' import type { Accessor, JSX, Resource } from 'solid-js'
import type { AuthModalSource } from '../components/Nav/AuthModal/types' import type { AuthModalSearchParams, AuthModalSource } from '../components/Nav/AuthModal/types'
import type { Author } from '../graphql/schema/core.gen' import type { Author } from '../graphql/schema/core.gen'
import { import {
@ -29,7 +29,6 @@ import {
import { inboxClient } from '../graphql/client/chat' import { inboxClient } from '../graphql/client/chat'
import { apiClient } from '../graphql/client/core' import { apiClient } from '../graphql/client/core'
import { notifierClient } from '../graphql/client/notifier'
import { useRouter } from '../stores/router' import { useRouter } from '../stores/router'
import { showModal } from '../stores/ui' import { showModal } from '../stores/ui'
import { addAuthors } from '../stores/zine/authors' import { addAuthors } from '../stores/zine/authors'
@ -136,6 +135,7 @@ export const SessionProvider = (props: {
const [isSessionLoaded, setIsSessionLoaded] = createSignal(false) const [isSessionLoaded, setIsSessionLoaded] = createSignal(false)
const [authError, setAuthError] = createSignal('') const [authError, setAuthError] = createSignal('')
const { clearSearchParams } = useRouter<AuthModalSearchParams>()
// Function to load session data // Function to load session data
const sessionData = async () => { const sessionData = async () => {
@ -143,7 +143,7 @@ export const SessionProvider = (props: {
const s: ApiResponse<AuthToken> = await authorizer().getSession() const s: ApiResponse<AuthToken> = await authorizer().getSession()
if (s?.data) { if (s?.data) {
console.info('[context.session] loading session', s) console.info('[context.session] loading session', s)
clearSearchParams()
// Set session expiration time in local storage // Set session expiration time in local storage
const expires_at = new Date(Date.now() + s.data.expires_in * 1000) const expires_at = new Date(Date.now() + s.data.expires_in * 1000)
localStorage.setItem('expires_at', `${expires_at.getTime()}`) localStorage.setItem('expires_at', `${expires_at.getTime()}`)
@ -199,6 +199,7 @@ export const SessionProvider = (props: {
} }
onCleanup(() => clearTimeout(minuteLater)) onCleanup(() => clearTimeout(minuteLater))
const authorData = async () => { const authorData = async () => {
const u = session()?.user const u = session()?.user
return u ? (await apiClient.getAuthorId({ user: u.id.trim() })) || null : null return u ? (await apiClient.getAuthorId({ user: u.id.trim() })) || null : null
@ -217,7 +218,18 @@ export const SessionProvider = (props: {
apiClient.connect(token) apiClient.connect(token)
inboxClient.connect(token) inboxClient.connect(token)
} }
if (!author()) loadAuthor()
try {
const appdata = session()?.user.app_data
if (appdata) {
const { profile } = appdata
setAuthor(profile)
addAuthors([profile])
if (!profile) loadAuthor()
}
} catch (e) {
console.error(e)
}
setIsSessionLoaded(true) setIsSessionLoaded(true)
} }
@ -263,7 +275,6 @@ export const SessionProvider = (props: {
() => { () => {
props.onStateChangeCallback(session()) props.onStateChangeCallback(session())
}, },
{ defer: true },
), ),
) )
@ -368,6 +379,7 @@ export const SessionProvider = (props: {
} }
const isAuthenticated = createMemo(() => Boolean(author())) const isAuthenticated = createMemo(() => Boolean(author()))
const actions = { const actions = {
loadSession, loadSession,
requireAuthentication, requireAuthentication,

View File

@ -1,6 +1,6 @@
import type { import type {
Author, Author,
AuthorFollows, AuthorFollowsResult,
CommonResult, CommonResult,
FollowingEntity, FollowingEntity,
LoadShoutsOptions, LoadShoutsOptions,
@ -134,7 +134,7 @@ export const apiClient = {
slug?: string slug?: string
author_id?: number author_id?: number
user?: string user?: string
}): Promise<AuthorFollows> => { }): Promise<AuthorFollowsResult> => {
const response = await publicGraphQLClient.query(authorFollows, params).toPromise() const response = await publicGraphQLClient.query(authorFollows, params).toPromise()
return response.data.get_author_follows return response.data.get_author_follows
}, },

View File

@ -56,17 +56,11 @@ export const AuthorPage = (props: PageProps) => {
onCleanup(() => resetSortedArticles()) onCleanup(() => resetSortedArticles())
const usePrerenderedData = props.author?.slug === slug()
return ( return (
<PageLayout title={props.seo?.title || t('Discours')}> <PageLayout title={props.seo?.title || t('Discours')}>
<ReactionsProvider> <ReactionsProvider>
<Show when={isLoaded()} fallback={<Loading />}> <Show when={isLoaded()} fallback={<Loading />}>
<AuthorView <AuthorView authorSlug={slug()} />
author={usePrerenderedData ? props.author : null}
shouts={usePrerenderedData ? props.authorShouts : null}
authorSlug={slug()}
/>
</Show> </Show>
</ReactionsProvider> </ReactionsProvider>
</PageLayout> </PageLayout>

View File

@ -320,3 +320,14 @@ h5 {
margin-bottom: 0; margin-bottom: 0;
} }
} }
// disable last pass extention
div[data-lastpass-icon-root="true"] {
opacity: 0 !important;
}
div[data-lastpass-infield="true"] {
opacity: 0 !important;
}

View File

@ -114,8 +114,8 @@ const handleClientRouteLinkClick = async (event) => {
} }
if (url.hash) { if (url.hash) {
scrollToHash(url.hash) // scrollToHash(url.hash)
return // return
} }
window.scrollTo({ window.scrollTo({

View File

@ -1,5 +1,5 @@
import { createLazyMemo } from '@solid-primitives/memo' import { createLazyMemo } from '@solid-primitives/memo'
import { createSignal } from 'solid-js' import { createEffect, createSignal } from 'solid-js'
import { apiClient } from '../../graphql/client/core' import { apiClient } from '../../graphql/client/core'
import { Author, QueryLoad_Authors_ByArgs } from '../../graphql/schema/core.gen' import { Author, QueryLoad_Authors_ByArgs } from '../../graphql/schema/core.gen'

View File

@ -588,6 +588,7 @@ figure {
display: block; display: block;
max-height: 90vh; max-height: 90vh;
margin: auto; margin: auto;
width: 100%;
} }
} }
@ -622,6 +623,10 @@ figure {
margin-bottom: 0.6em; margin-bottom: 0.6em;
white-space: nowrap; white-space: nowrap;
@include media-breakpoint-up(md) {
margin-right: 2.4rem;
}
.link { .link {
border-bottom: none; border-bottom: none;
} }

View File

@ -1,26 +1,41 @@
import { cdnUrl, thumborUrl } from './config' import { cdnUrl, thumborUrl } from './config'
const getSizeUrlPart = (options: { width?: number; height?: number; noSizeUrlPart?: boolean } = {}) => { const URL_CONFIG = {
const widthString = options.width ? options.width.toString() : '' cdnUrl: cdnUrl,
const heightString = options.height ? options.height.toString() : '' thumborUrl: `${thumborUrl}/unsafe/`,
audioSubfolder: 'audio',
imageSubfolder: 'image',
productionFolder: 'production/',
}
if (!(widthString || heightString) || options.noSizeUrlPart) { const AUDIO_EXTENSIONS = new Set(['wav', 'mp3', 'ogg', 'aif', 'flac'])
return ''
}
return `${widthString}x${heightString}/` const isAudioFile = (filename: string): boolean => {
const extension = filename.split('.').pop()?.toLowerCase()
return AUDIO_EXTENSIONS.has(extension ?? '')
}
const getLastSegment = (url: string): string => url.toLowerCase().split('/').pop() || ''
const buildSizePart = (width?: number, height?: number, includeSize = true): string => {
if (!includeSize) return ''
const widthPart = width ? width.toString() : ''
const heightPart = height ? height.toString() : ''
return widthPart || heightPart ? `${widthPart}x${heightPart}/` : ''
} }
export const getImageUrl = ( export const getImageUrl = (
src: string, src: string,
options: { width?: number; height?: number; noSizeUrlPart?: boolean } = {}, options: { width?: number; height?: number; noSizeUrlPart?: boolean } = {},
) => { ): string => {
const filename = src?.split('/').pop() if (!src.includes('discours.io') && src.includes('http')) {
const isAudio = src.toLowerCase().split('.').pop() in ['wav', 'mp3', 'ogg', 'aif', 'flac'] return src
const base = isAudio ? cdnUrl : `${thumborUrl}/unsafe/` }
const sizeUrlPart = isAudio ? '' : getSizeUrlPart(options) const filename = getLastSegment(src)
const base = isAudioFile(filename) ? URL_CONFIG.cdnUrl : URL_CONFIG.thumborUrl
const suffix = options.noSizeUrlPart ? '' : buildSizePart(options.width, options.height)
const subfolder = isAudioFile(filename) ? URL_CONFIG.audioSubfolder : URL_CONFIG.imageSubfolder
return `${base}${sizeUrlPart}production/${isAudio ? 'audio' : 'image'}/${filename}` return `${base}${suffix}${URL_CONFIG.productionFolder}${subfolder}/${filename}`
} }
export const getOpenGraphImageUrl = ( export const getOpenGraphImageUrl = (
@ -32,17 +47,16 @@ export const getOpenGraphImageUrl = (
width?: number width?: number
height?: number height?: number
}, },
) => { ): string => {
const sizeUrlPart = getSizeUrlPart(options) const sizeUrlPart = buildSizePart(options.width, options.height)
const filtersPart = `filters:discourstext('${encodeURIComponent(options.topic)}','${encodeURIComponent( const filtersPart = `filters:discourstext('${encodeURIComponent(options.topic)}','${encodeURIComponent(
options.author, options.author,
)}','${encodeURIComponent(options.title)}')/` )}','${encodeURIComponent(options.title)}')/`
if (src.startsWith(thumborUrl)) { if (src.startsWith(URL_CONFIG.thumborUrl)) {
const thumborKey = src.replace(`${thumborUrl}/unsafe`, '') const thumborKey = src.replace(URL_CONFIG.thumborUrl, '')
return `${thumborUrl}/unsafe/${sizeUrlPart}${filtersPart}${thumborKey}` return `${URL_CONFIG.thumborUrl}${sizeUrlPart}${filtersPart}${thumborKey}`
} }
return `${thumborUrl}/unsafe/${sizeUrlPart}${filtersPart}${src}` return `${URL_CONFIG.thumborUrl}${sizeUrlPart}${filtersPart}${src}`
} }