This commit is contained in:
Untone 2023-10-18 11:02:52 +03:00
commit 328acd9ce8
44 changed files with 524 additions and 271 deletions

11
package-lock.json generated
View File

@ -13,6 +13,7 @@
"i18next": "22.4.15", "i18next": "22.4.15",
"i18next-icu": "2.3.0", "i18next-icu": "2.3.0",
"intl-messageformat": "10.5.3", "intl-messageformat": "10.5.3",
"just-throttle": "4.2.0",
"mailgun.js": "8.2.1", "mailgun.js": "8.2.1",
"node-fetch": "3.3.1" "node-fetch": "3.3.1"
}, },
@ -13168,6 +13169,11 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/just-throttle": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/just-throttle/-/just-throttle-4.2.0.tgz",
"integrity": "sha512-/iAZv1953JcExpvsywaPKjSzfTiCLqeguUTE6+VmK15mOcwxBx7/FHrVvS4WEErMR03TRazH8kcBSHqMagYIYg=="
},
"node_modules/kebab-case": { "node_modules/kebab-case": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz", "resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz",
@ -28106,6 +28112,11 @@
"object.values": "^1.1.6" "object.values": "^1.1.6"
} }
}, },
"just-throttle": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/just-throttle/-/just-throttle-4.2.0.tgz",
"integrity": "sha512-/iAZv1953JcExpvsywaPKjSzfTiCLqeguUTE6+VmK15mOcwxBx7/FHrVvS4WEErMR03TRazH8kcBSHqMagYIYg=="
},
"kebab-case": { "kebab-case": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz", "resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz",

View File

@ -33,6 +33,7 @@
"i18next": "22.4.15", "i18next": "22.4.15",
"i18next-icu": "2.3.0", "i18next-icu": "2.3.0",
"intl-messageformat": "10.5.3", "intl-messageformat": "10.5.3",
"just-throttle": "4.2.0",
"mailgun.js": "8.2.1", "mailgun.js": "8.2.1",
"node-fetch": "3.3.1" "node-fetch": "3.3.1"
}, },

4
public/icons/key.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6 0.25C2.87109 0.25 0.375 2.76758 0.375 5.875C0.375 8.0332 1.5918 9.9043 3.375 10.8477V17.3809C3.375 17.791 3.5625 18.1777 3.88477 18.4336L5.24219 19.5059C5.44727 19.6699 5.69531 19.75 5.94141 19.75C6.2168 19.75 6.49219 19.6484 6.70898 19.4492L9.15625 17.1797C9.33789 17.0117 9.39062 16.7461 9.28906 16.5215L8.625 15.0586V15.0234L9.52734 13.9043C9.79688 13.5684 9.8125 13.0918 9.56055 12.7402L8.625 11.4316V10.8477C10.4082 9.9043 11.625 8.0332 11.625 5.875C11.625 2.76758 9.12891 0.25 6 0.25ZM6 1.75C8.27344 1.75 10.125 3.60156 10.125 5.875C10.125 7.40625 9.28125 8.80469 7.92383 9.52148L7.125 9.94336V11.9121L8.10352 13.2812L7.125 14.4941V15.3828L7.64648 16.5332L5.92383 18.1328L4.97852 17.3848C4.91211 17.334 4.875 17.2559 4.875 17.1719V9.94336L4.07617 9.52148C2.71875 8.80469 1.875 7.40625 1.875 5.875C1.875 3.60156 3.72656 1.75 6 1.75ZM6 3.25C5.17188 3.25 4.5 3.92188 4.5 4.75C4.5 5.57812 5.17188 6.25 6 6.25C6.82812 6.25 7.5 5.57812 7.5 4.75C7.5 3.92188 6.82812 3.25 6 3.25Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -28,6 +28,8 @@
"All posts": "All posts", "All posts": "All posts",
"All topics": "All topics", "All topics": "All topics",
"Almost done! Check your email.": "Almost done! Just checking your email.", "Almost done! Check your email.": "Almost done! Just checking your email.",
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
"Are you sure you want to delete this draft?": "Are you sure you want to delete this draft?",
"Are you sure you want to to proceed the action?": "Are you sure you want to to proceed the action?", "Are you sure you want to to proceed the action?": "Are you sure you want to to proceed the action?",
"Art": "Art", "Art": "Art",
"Artist": "Artist", "Artist": "Artist",
@ -100,6 +102,7 @@
"Discussion rules": "Discussion rules", "Discussion rules": "Discussion rules",
"Discussions": "Discussions", "Discussions": "Discussions",
"Dogma": "Dogma", "Dogma": "Dogma",
"Draft successfully deleted": "Draft successfully deleted",
"Drafts": "Drafts", "Drafts": "Drafts",
"Drag the image to this area": "Drag the image to this area", "Drag the image to this area": "Drag the image to this area",
"Each image must be no larger than 5 MB.": "Each image must be no larger than 5 MB.", "Each image must be no larger than 5 MB.": "Each image must be no larger than 5 MB.",
@ -193,6 +196,7 @@
"Manifesto": "Manifesto", "Manifesto": "Manifesto",
"Many files, choose only one": "Many files, choose only one", "Many files, choose only one": "Many files, choose only one",
"Material card": "Material card", "Material card": "Material card",
"Message": "Message",
"More": "More", "More": "More",
"Most commented": "Commented", "Most commented": "Commented",
"Most read": "Readable", "Most read": "Readable",
@ -206,12 +210,19 @@
"New only": "New only", "New only": "New only",
"New password": "New password", "New password": "New password",
"New stories every day and even more!": "New stories and more are waiting for you every day!", "New stories every day and even more!": "New stories and more are waiting for you every day!",
"NewCommentNotificationText": "{commentsCount, plural, one {New comment} other {{commentsCount} comments}} to your publication {shoutTitle} from {lastCommenterName}{restUsersCount, plural, =0 {} one { one more user} other { and more {restUsersCount} users}}",
"NewReplyNotificationText": "{commentsCount, plural, one {New reply} other {{commentsCount} replays} other {{commentsCount} новых ответов}} to your publication {shoutTitle} от {lastCommenterName}{restUsersCount, plural, =0 {} one { and one more user} other { and more {restUsersCount} users}}", "NotificationNewCommentText1": "{commentsCount, plural, one {New comment} other {{commentsCount} comments}} to your publication",
"NotificationNewCommentText2": "from",
"NotificationNewCommentText3": "{restUsersCount, plural, =0 {} one { one more user} other { and more {restUsersCount} users}}",
"NotificationNewReplyText1": "{commentsCount, plural, one {New reply} other {{commentsCount} replays}} to your publication",
"NotificationNewReplyText2": "from",
"NotificationNewReplyText3": "{restUsersCount, plural, =0 {} one { and one more user} other { and more {restUsersCount} users}}",
"Newsletter": "Newsletter", "Newsletter": "Newsletter",
"Night mode": "Night mode", "Night mode": "Night mode",
"No notifications, yet": "No notifications, yet", "No notifications yet": "No notifications yet",
"No such account, please try to register": "No such account found, please try to register", "Write good articles, comment\nand it won't be so empty here": "Write good articles, comment\nand it won't be so empty here",
"Nothing here yet": "There's nothing here yet", "Nothing here yet": "There's nothing here yet",
"Nothing is here": "There is nothing here", "Nothing is here": "There is nothing here",
"Notifications": "Notifications", "Notifications": "Notifications",
@ -353,7 +364,6 @@
"Where": "From", "Where": "From",
"Words": "Слов", "Words": "Слов",
"Work with us": "Cooperate with Discourse", "Work with us": "Cooperate with Discourse",
"Message": "Message",
"Write a comment...": "Write a comment...", "Write a comment...": "Write a comment...",
"Write a short introduction": "Write a short introduction", "Write a short introduction": "Write a short introduction",
"Write about the topic": "Write about the topic", "Write about the topic": "Write about the topic",

View File

@ -31,6 +31,8 @@
"All posts": "Все публикации", "All posts": "Все публикации",
"All topics": "Все темы", "All topics": "Все темы",
"Almost done! Check your email.": "Почти готово! Осталось подтвердить вашу почту.", "Almost done! Check your email.": "Почти готово! Осталось подтвердить вашу почту.",
"Are you sure you want to delete this comment?": "Уверены, что хотите удалить этот комментарий?",
"Are you sure you want to delete this draft?": "Уверены, что хотите удалить этот черновик?",
"Are you sure you want to to proceed the action?": "Вы уверены, что хотите продолжить?", "Are you sure you want to to proceed the action?": "Вы уверены, что хотите продолжить?",
"Art": "Искусство", "Art": "Искусство",
"Artist": "Исполнитель", "Artist": "Исполнитель",
@ -104,6 +106,7 @@
"Discussion rules": "Правила сообществ самиздата в&nbsp;соцсетях", "Discussion rules": "Правила сообществ самиздата в&nbsp;соцсетях",
"Discussions": "Дискуссии", "Discussions": "Дискуссии",
"Dogma": "Догма", "Dogma": "Догма",
"Draft successfully deleted": "Черновик успешно удален",
"Drafts": "Черновики", "Drafts": "Черновики",
"Drag the image to this area": "Перетащите изображение в эту область", "Drag the image to this area": "Перетащите изображение в эту область",
"Each image must be no larger than 5 MB.": "Каждое изображение должно быть размером не больше 5 мб.", "Each image must be no larger than 5 MB.": "Каждое изображение должно быть размером не больше 5 мб.",
@ -217,11 +220,19 @@
"New only": "Только новые", "New only": "Только новые",
"New password": "Новый пароль", "New password": "Новый пароль",
"New stories every day and even more!": "Каждый день вас ждут новые истории и ещё много всего интересного!", "New stories every day and even more!": "Каждый день вас ждут новые истории и ещё много всего интересного!",
"NewCommentNotificationText": "{commentsCount, plural, one {Новый комментарий} few {{commentsCount} новых комментария} other {{commentsCount} новых комментариев}} к вашей публикации {shoutTitle} от {lastCommenterName}{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}",
"NewReplyNotificationText": "{commentsCount, plural, one {Новый ответ} few {{commentsCount} новых ответа} other {{commentsCount} новых ответов}} к вашему комментарию к публикации {shoutTitle} от {lastCommenterName}{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}", "NotificationNewCommentText1": "{commentsCount, plural, one {Новый комментарий} few {{commentsCount} новых комментария} other {{commentsCount} новых комментариев}} к вашей публикации",
"NotificationNewCommentText2": "от",
"NotificationNewCommentText3": "{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}",
"NotificationNewReplyText1": "{commentsCount, plural, one {Новый ответ} few {{commentsCount} новых ответа} other {{commentsCount} новых ответов}} на ваш комментарий к публикации",
"NotificationNewReplyText2": "от",
"NotificationNewReplyText3": "{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}",
"Newsletter": "Рассылка", "Newsletter": "Рассылка",
"Night mode": "Ночная тема", "Night mode": "Ночная тема",
"No notifications, yet": "Тут пока пусто", "No notifications yet": "Уведомлений пока нет",
"Write good articles, comment\nand it won't be so empty here": "Пишите хорошие статьи, комментируйте,\nи здесь станет не так пусто",
"No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться", "No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться",
"Nothing here yet": "Здесь пока ничего нет", "Nothing here yet": "Здесь пока ничего нет",
"Nothing is here": "Здесь ничего нет", "Nothing is here": "Здесь ничего нет",

View File

@ -1,5 +1,5 @@
.comment { .comment {
margin: 0.5em 0; margin: 0 0 0.5em;
padding: 1rem; padding: 1rem;
transition: background-color 0.3s; transition: background-color 0.3s;
position: relative; position: relative;

View File

@ -62,7 +62,12 @@ export const Comment = (props: Props) => {
const remove = async () => { const remove = async () => {
if (comment()?.id) { if (comment()?.id) {
try { try {
const isConfirmed = await showConfirm() const isConfirmed = await showConfirm({
confirmBody: t('Are you sure you want to delete this comment?'),
confirmButtonLabel: t('Delete'),
confirmButtonVariant: 'danger',
declineButtonVariant: 'primary'
})
if (isConfirmed) { if (isConfirmed) {
await deleteReaction(comment().id) await deleteReaction(comment().id)
@ -136,7 +141,7 @@ export const Comment = (props: Props) => {
})} })}
/> />
<small> <small>
<a href={`#comment-${comment()?.id}`}>{comment()?.shout.title || ''}</a> <a href={`#comment_${comment()?.id}`}>{comment()?.shout.title || ''}</a>
</small> </small>
</div> </div>
} }
@ -174,7 +179,7 @@ export const Comment = (props: Props) => {
<CommentRatingControl comment={comment()} /> <CommentRatingControl comment={comment()} />
</div> </div>
</Show> </Show>
<div class={styles.commentBody} id={'comment-' + (comment().id || '')}> <div class={styles.commentBody}>
<Show when={editMode()} fallback={<MD body={body()} />}> <Show when={editMode()} fallback={<MD body={body()} />}>
<Suspense fallback={<p>{t('Loading')}</p>}> <Suspense fallback={<p>{t('Loading')}</p>}>
<SimplifiedEditor <SimplifiedEditor

View File

@ -4,14 +4,15 @@
align-self: center; align-self: center;
display: flex; display: flex;
flex: 1; flex: 1;
flex-wrap: wrap;
@include font-size(1.2rem); @include font-size(1.2rem);
font-size: 1.2rem; font-size: 1.2rem;
gap: 1rem;
justify-content: flex-start; justify-content: flex-start;
margin: 0 1em 0 0; margin: 0 1em 0 0;
.date { .date {
font-weight: 500; font-weight: 500;
margin-right: 1rem;
.icon { .icon {
line-height: 1; line-height: 1;

View File

@ -1,4 +1,4 @@
import { Show, createMemo, createSignal, onMount, For, createEffect } from 'solid-js' import { Show, createMemo, createSignal, onMount, For } from 'solid-js'
import { Comment } from './Comment' import { Comment } from './Comment'
import styles from './Article.module.scss' import styles from './Article.module.scss'
import { clsx } from 'clsx' import { clsx } from 'clsx'
@ -181,7 +181,7 @@ export const CommentsTree = (props: Props) => {
<SimplifiedEditor <SimplifiedEditor
quoteEnabled={true} quoteEnabled={true}
imageEnabled={true} imageEnabled={true}
autoFocus={true} autoFocus={false}
submitByCtrlEnter={true} submitByCtrlEnter={true}
placeholder={t('Write a comment...')} placeholder={t('Write a comment...')}
onSubmit={(value) => handleSubmitComment(value)} onSubmit={(value) => handleSubmitComment(value)}

View File

@ -28,11 +28,26 @@ import styles from './Article.module.scss'
import { CardTopic } from '../Feed/CardTopic' import { CardTopic } from '../Feed/CardTopic'
import { createPopper } from '@popperjs/core' import { createPopper } from '@popperjs/core'
interface Props { type Props = {
article: Shout article: Shout
scrollToComments?: boolean scrollToComments?: boolean
} }
export type ArticlePageSearchParams = {
scrollTo: 'comments'
commentId: string
}
const scrollTo = (el: HTMLElement) => {
const { top } = el.getBoundingClientRect()
window.scrollTo({
top: top + window.scrollY - 96,
left: 0,
behavior: 'smooth'
})
}
export const FullArticle = (props: Props) => { export const FullArticle = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { const {
@ -77,16 +92,15 @@ export const FullArticle = (props: Props) => {
return JSON.parse(props.article.media || '[]') return JSON.parse(props.article.media || '[]')
}) })
const commentsRef: { current: HTMLDivElement } = { current: null } const commentsRef: {
current: HTMLDivElement
} = { current: null }
const scrollToComments = () => { const scrollToComments = () => {
window.scrollTo({ scrollTo(commentsRef.current)
top: commentsRef.current.offsetTop - 96,
left: 0,
behavior: 'smooth'
})
} }
const { searchParams, changeSearchParam } = useRouter() const { searchParams, changeSearchParam } = useRouter<ArticlePageSearchParams>()
createEffect(() => { createEffect(() => {
if (props.scrollToComments) { if (props.scrollToComments) {
@ -105,9 +119,14 @@ export const FullArticle = (props: Props) => {
createEffect(() => { createEffect(() => {
if (searchParams().commentId && isReactionsLoaded()) { if (searchParams().commentId && isReactionsLoaded()) {
const commentElement = document.querySelector(`[id='comment_${searchParams().commentId}']`) const commentElement = document.querySelector<HTMLElement>(
`[id='comment_${searchParams().commentId}']`
)
changeSearchParam({ commentId: null })
if (commentElement) { if (commentElement) {
commentElement.scrollIntoView({ behavior: 'smooth' }) scrollTo(commentElement)
} }
} }
}) })

View File

@ -9,6 +9,10 @@
margin-bottom: 3rem; margin-bottom: 3rem;
} }
@include media-breakpoint-down(md) {
text-align: left;
}
.info { .info {
@include font-size(1.4rem); @include font-size(1.4rem);
border: none; border: none;

View File

@ -42,7 +42,7 @@
flex: 1; flex: 1;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
align-items: baseline; align-items: center;
display: flex; display: flex;
} }
@ -426,6 +426,10 @@
} }
} }
.shareControl {
display: inline-block;
}
.buttonSubscribe { .buttonSubscribe {
align-items: center; align-items: center;
aspect-ratio: 1/1; aspect-ratio: 1/1;
@ -447,8 +451,10 @@
} }
.buttonWrite { .buttonWrite {
background: #ccc;
color: #000; color: #000;
display: inline-flex; display: inline-flex;
font-weight: 500;
transition: transition:
background-color 0.3s, background-color 0.3s,
color 0.3s; color 0.3s;
@ -490,6 +496,7 @@
color: #696969; color: #696969;
@include font-size(2rem); @include font-size(2rem);
font-weight: 500; font-weight: 500;
margin-top: 1.5rem;
} }
.authorSubscribe { .authorSubscribe {

View File

@ -208,8 +208,7 @@ export const AuthorCard = (props: Props) => {
} }
> >
<div class={styles.subscribersContainer}> <div class={styles.subscribersContainer}>
<Switch> <Show when={props.followers && props.followers.length > 0}>
<Match when={props.followers && props.followers.length > 0 && !props.isCurrentUser}>
<a href="?modal=followers" class={styles.subscribers}> <a href="?modal=followers" class={styles.subscribers}>
<For each={props.followers.slice(0, 3)}> <For each={props.followers.slice(0, 3)}>
{(f) => <Userpic name={f.name} userpic={f.userpic} class={styles.userpic} />} {(f) => <Userpic name={f.name} userpic={f.userpic} class={styles.userpic} />}
@ -218,18 +217,9 @@ export const AuthorCard = (props: Props) => {
{t('SubscriberWithCount', { count: props.followers.length })} {t('SubscriberWithCount', { count: props.followers.length })}
</div> </div>
</a> </a>
</Match> </Show>
<Match when={props.followers && props.followers.length > 0 && props.isCurrentUser}>
<Button
variant="secondary"
onClick={() => redirectPage(router, 'profileSettings')}
value={t('Edit profile')}
/>
</Match>
</Switch>
<Switch> <Show when={props.following && props.following.length > 0}>
<Match when={!props.isCurrentUser && props.following && props.following.length > 0}>
<a href="?modal=following" class={styles.subscribers}> <a href="?modal=following" class={styles.subscribers}>
<For each={props.following.slice(0, 3)}> <For each={props.following.slice(0, 3)}>
{(f) => { {(f) => {
@ -245,23 +235,13 @@ export const AuthorCard = (props: Props) => {
{t('SubscriptionWithCount', { count: props?.following.length ?? 0 })} {t('SubscriptionWithCount', { count: props?.following.length ?? 0 })}
</div> </div>
</a> </a>
</Match> </Show>
<Match when={props.isCurrentUser && props.following && props.following.length > 0}>
<SharePopup
containerCssClass={stylesHeader.control}
title={props.author.name}
description={props.author.bio}
imageUrl={props.author.userpic}
shareUrl={getShareUrl({ pathname: `/author/${props.author.slug}` })}
trigger={<Button variant="secondary" value={t('Share')} />}
/>
</Match>
</Switch>
</div> </div>
</Show> </Show>
</div> </div>
<ShowOnlyOnClient> <ShowOnlyOnClient>
<Show when={isSessionLoaded() && props.author.links}> <Show when={isSessionLoaded()}>
<Show when={props.author.links && props.author.links.length > 0}>
<div class={styles.authorSubscribeSocial}> <div class={styles.authorSubscribeSocial}>
<For each={props.author.links}> <For each={props.author.links}>
{(link) => ( {(link) => (
@ -278,6 +258,7 @@ export const AuthorCard = (props: Props) => {
)} )}
</For> </For>
</div> </div>
</Show>
<Show when={canFollow()}> <Show when={canFollow()}>
<div class={styles.authorSubscribe}> <div class={styles.authorSubscribe}>
<Show <Show
@ -346,9 +327,9 @@ export const AuthorCard = (props: Props) => {
<Show when={!props.hideWriteButton}> <Show when={!props.hideWriteButton}>
<button <button
class={clsx(styles.button, styles.buttonSubscribe)} class={styles.button}
classList={{ classList={{
'button--subscribe': !props.isAuthorsList, 'button--light': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList, 'button--subscribe-topic': props.isAuthorsList,
[styles.buttonWrite]: props.liteButtons && props.isAuthorsList [styles.buttonWrite]: props.liteButtons && props.isAuthorsList
}} }}
@ -362,6 +343,26 @@ export const AuthorCard = (props: Props) => {
</Show> </Show>
</div> </div>
</Show> </Show>
<Show when={props.isCurrentUser}>
<div class={styles.authorSubscribe}>
<Button
variant="secondary"
onClick={() => redirectPage(router, 'profileSettings')}
value={t('Edit profile')}
class={styles.button}
/>
<SharePopup
containerCssClass={styles.shareControl}
title={props.author.name}
description={props.author.bio}
imageUrl={props.author.userpic}
shareUrl={getShareUrl({ pathname: `/author/${props.author.slug}` })}
trigger={<Button variant="secondary" value={t('Share')} />}
/>
</div>
</Show>
</Show> </Show>
</ShowOnlyOnClient> </ShowOnlyOnClient>
<Show when={props.followers}> <Show when={props.followers}>

View File

@ -35,11 +35,16 @@ export const Draft = (props: Props) => {
const handleDeleteLinkClick = async (e) => { const handleDeleteLinkClick = async (e) => {
e.preventDefault() e.preventDefault()
const isConfirmed = await showConfirm() const isConfirmed = await showConfirm({
confirmBody: t('Are you sure you want to delete this draft?'),
confirmButtonLabel: t('Delete'),
confirmButtonVariant: 'danger',
declineButtonVariant: 'primary'
})
if (isConfirmed) { if (isConfirmed) {
props.onDelete(props.shout) props.onDelete(props.shout)
await showSnackbar({ type: 'success', body: t('Success') }) await showSnackbar({ body: t('Draft successfully deleted') })
} }
} }

View File

@ -115,12 +115,8 @@
} }
} }
.shoutAuthor,
.shoutDate {
@include font-size(1.4rem);
}
.shoutAuthor { .shoutAuthor {
@include font-size(1.4rem);
font-weight: 500; font-weight: 500;
margin-right: 1.6rem; margin-right: 1.6rem;
@ -139,9 +135,11 @@
.shoutDate { .shoutDate {
color: #9fa1a7; color: #9fa1a7;
font-weight: 500; font-weight: 500;
@include font-size(1.2rem);
} }
.shoutDetails { .shoutDetails {
align-items: end;
display: flex; display: flex;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -149,8 +147,7 @@
.shoutDetailsFeedMode { .shoutDetailsFeedMode {
justify-content: space-between; justify-content: space-between;
.shoutAuthor, .shoutAuthor {
.shoutDate {
@include font-size(1.2rem); @include font-size(1.2rem);
} }
} }
@ -194,19 +191,27 @@
font-weight: 400; font-weight: 400;
line-height: 1.3; line-height: 1.3;
margin-bottom: 1.4rem; margin-bottom: 1.4rem;
transition: color 0.2s, background-color 0.2s, box-shadow 0.2s; transition:
color 0.2s,
background-color 0.2s,
box-shadow 0.2s;
} }
.shoutCardLinkContainer { .shoutCardLinkContainer {
position: relative; position: relative;
transition: color 0.2s, background-color 0.2s, box-shadow 0.2s; transition:
color 0.2s,
background-color 0.2s,
box-shadow 0.2s;
} }
.shoutCardEditControl { .shoutCardEditControl {
border-radius: 2em; border-radius: 2em;
min-height: 2.6em; min-height: 2.6em;
padding: 0 1.4em; padding: 0 1.4em;
transition: background-color 0.2s, color 0.2s; transition:
background-color 0.2s,
color 0.2s;
&:hover { &:hover {
background: var(--background-color-invert); background: var(--background-color-invert);

View File

@ -1,9 +1,11 @@
.sidebar { .sidebar {
margin-top: -0.7rem;
max-height: calc(100vh - 120px); max-height: calc(100vh - 120px);
overflow: auto; overflow: auto;
top: 120px; top: 120px;
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
margin-top: 0;
position: sticky; position: sticky;
ul > li { ul > li {

View File

@ -7,7 +7,6 @@ import formattedTime from '../../utils/formatDateTime'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { MessageActionsPopup } from './MessageActionsPopup' import { MessageActionsPopup } from './MessageActionsPopup'
import QuotedMessage from './QuotedMessage' import QuotedMessage from './QuotedMessage'
import MD from '../Article/MD'
type Props = { type Props = {
content: MessageType content: MessageType

View File

@ -1,48 +1,22 @@
.confirmModal { .confirmModal {
background: #fff;
min-height: 550px;
position: relative; position: relative;
@include media-breakpoint-up(md) { .confirmModalTitle {
min-height: 710px; @include font-size(2rem);
}
}
.confirmModalTitle {
font-size: 26px;
line-height: 32px;
font-weight: 700; font-weight: 700;
color: #141414; color: var(--default-color);
text-align: left; text-align: center;
} }
.confirmModalActions { .confirmModalActions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-top: 16px; margin-top: 4rem;
} gap: 2rem;
.confirmModalButton { .confirmAction {
display: block; flex: 1;
width: 100%; }
margin-right: 12px;
font-weight: 700;
margin-top: 32px;
padding: 1.6rem !important;
border: 1px solid black;
&:hover {
background-color: rgb(0 0 0 / 8%);
}
}
.confirmModalButtonPrimary {
margin-right: 0;
background-color: black;
color: white;
border: none;
&:hover {
background-color: rgb(0 0 0 / 60%);
} }
} }

View File

@ -1,7 +1,7 @@
import { clsx } from 'clsx'
import { useConfirm } from '../../../context/confirm' import { useConfirm } from '../../../context/confirm'
import styles from './ConfirmModal.module.scss'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { Button } from '../../_shared/Button'
import styles from './ConfirmModal.module.scss'
export const ConfirmModal = () => { export const ConfirmModal = () => {
const { t } = useLocalize() const { t } = useLocalize()
@ -12,21 +12,26 @@ export const ConfirmModal = () => {
} = useConfirm() } = useConfirm()
return ( return (
<div> <div class={styles.confirmModal}>
<h4 class={styles.confirmModalTitle}> <h4 class={styles.confirmModalTitle}>
{confirmMessage().confirmBody ?? t('Are you sure you want to to proceed the action?')} {confirmMessage().confirmBody ?? t('Are you sure you want to to proceed the action?')}
</h4> </h4>
<div class={styles.confirmModalActions}> <div class={styles.confirmModalActions}>
<button class={styles.confirmModalButton} onClick={() => resolveConfirm(false)}> <Button
{confirmMessage().declineButtonLabel ?? t('Decline')} onClick={() => resolveConfirm(false)}
</button> value={confirmMessage().declineButtonLabel ?? t('Decline')}
<button size="L"
class={clsx(styles.confirmModalButton, styles.confirmModalButtonPrimary)} variant={confirmMessage().declineButtonVariant ?? 'secondary'}
class={styles.confirmAction}
/>
<Button
onClick={() => resolveConfirm(true)} onClick={() => resolveConfirm(true)}
> value={confirmMessage().confirmButtonLabel ?? t('Confirm')}
{confirmMessage().confirmButtonLabel ?? t('Confirm')} size="L"
</button> variant={confirmMessage().confirmButtonVariant ?? 'primary'}
class={styles.confirmAction}
/>
</div> </div>
</div> </div>
) )

View File

@ -104,9 +104,9 @@
position: relative; position: relative;
@include media-breakpoint-down(lg) { @include media-breakpoint-down(lg) {
flex: 1 !important;
max-width: 100% !important; max-width: 100% !important;
padding: 0 !important; position: absolute;
right: 0;
} }
} }
@ -139,7 +139,7 @@
overflow: auto; overflow: auto;
padding: $container-padding-x !important; padding: $container-padding-x !important;
position: fixed; position: fixed;
top: 64px; top: 58px;
width: 100%; width: 100%;
z-index: 1; z-index: 1;
@ -191,8 +191,9 @@
} }
} }
ul { :global(.view-switcher) {
margin-top: 0; margin-top: 0;
overflow: hidden;
} }
li { li {
@ -217,6 +218,10 @@
.fixed & { .fixed & {
display: block; display: block;
} }
a {
padding-top: 0.1em;
}
} }
.mainNavigationSocial a { .mainNavigationSocial a {
@ -246,6 +251,30 @@
background: #f7f7f8; background: #f7f7f8;
border: none; border: none;
border-radius: 1.6rem; border-radius: 1.6rem;
padding-right: 5.6rem;
&:not(:placeholder-shown) {
& ~ .mobileSubscriptionSubmit {
display: block;
}
}
}
}
.mobileSubscriptionSubmit {
aspect-ratio: 1/1;
display: none;
height: 100%;
position: absolute;
right: 0;
top: 0;
img {
aspect-ratio: 1/1;
left: 50%;
position: relative;
transform: translateX(-50%);
width: 16px !important;
} }
} }
@ -387,15 +416,11 @@
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
position: absolute; position: absolute;
right: 5rem; right: 0;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 100%; width: 100%;
@include media-breakpoint-up(lg) {
right: 0;
}
@include media-breakpoint-up(xl) { @include media-breakpoint-up(xl) {
right: 2rem; right: 2rem;
} }
@ -446,10 +471,6 @@
z-index: -1; z-index: -1;
} }
@include media-breakpoint-down(md) {
padding: divide($container-padding-x, 2);
}
.userpic { .userpic {
align-items: center; align-items: center;
margin-right: 0; margin-right: 0;
@ -491,6 +512,7 @@
a, a,
a:link { a:link {
border: none; border: none;
cursor: pointer;
height: auto; height: auto;
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -534,6 +556,12 @@
} }
&:global(.loginbtn) { &:global(.loginbtn) {
background: #e9e9ee;
@include media-breakpoint-up(xl) {
background: none;
}
.icon { .icon {
height: 2.4rem; height: 2.4rem;
width: 2.4rem; width: 2.4rem;

View File

@ -67,7 +67,7 @@ export const Header = (props: Props) => {
let windowScrollTop = 0 let windowScrollTop = 0
createEffect(() => { createEffect(() => {
const mainContent = document.querySelector('.main-content') as HTMLDivElement const mainContent = document.querySelector<HTMLDivElement>('.main-content')
if (fixed() || modal() !== null) { if (fixed() || modal() !== null) {
windowScrollTop = window.scrollY windowScrollTop = window.scrollY
@ -173,6 +173,11 @@ export const Header = (props: Props) => {
<div class={clsx(styles.mainHeaderInner, 'wide-container')}> <div class={clsx(styles.mainHeaderInner, 'wide-container')}>
<nav class={clsx('row', styles.headerInner, { ['fixed']: fixed() })}> <nav class={clsx('row', styles.headerInner, { ['fixed']: fixed() })}>
<div class={clsx(styles.burgerContainer, 'col-auto')}>
<div class={styles.burger} classList={{ fixed: fixed() }} onClick={toggleFixed}>
<div />
</div>
</div>
<div class={clsx('col-md-5 col-xl-4 col-auto', styles.mainLogo)}> <div class={clsx('col-md-5 col-xl-4 col-auto', styles.mainLogo)}>
<a href={getPagePath(router, 'home')}> <a href={getPagePath(router, 'home')}>
<img src="/logo.svg" alt={t('Discours')} /> <img src="/logo.svg" alt={t('Discours')} />
@ -214,6 +219,7 @@ export const Header = (props: Props) => {
<Link <Link
onMouseOver={() => toggleSubnavigation(true, setIsKnowledgeBaseVisible)} onMouseOver={() => toggleSubnavigation(true, setIsKnowledgeBaseVisible)}
onMouseOut={() => hideSubnavigation} onMouseOut={() => hideSubnavigation}
routeName="guide"
body={t('Knowledge base')} body={t('Knowledge base')}
active={isKnowledgeBaseVisible()} active={isKnowledgeBaseVisible()}
/> />
@ -278,6 +284,9 @@ export const Header = (props: Props) => {
<div class="pretty-form__item"> <div class="pretty-form__item">
<input type="email" placeholder="Ваш email" id="subscription-email" /> <input type="email" placeholder="Ваш email" id="subscription-email" />
<label for="subscription-email">{t('Your email')}</label> <label for="subscription-email">{t('Your email')}</label>
<button class={styles.mobileSubscriptionSubmit}>
<Icon name="arrow-right" />
</button>
</div> </div>
</form> </form>
@ -327,11 +336,6 @@ export const Header = (props: Props) => {
</a> </a>
</div> </div>
</Show> </Show>
<div class={clsx(styles.burgerContainer, 'col-auto')}>
<div class={styles.burger} classList={{ fixed: fixed() }} onClick={toggleFixed}>
<div />
</div>
</div>
<div <div
class={clsx(styles.subnavigation, 'col')} class={clsx(styles.subnavigation, 'col')}

View File

@ -108,7 +108,7 @@ export const HeaderAuth = (props: Props) => {
return ( return (
<ShowOnlyOnClient> <ShowOnlyOnClient>
<Show when={isSessionLoaded()} keyed={true}> <Show when={isSessionLoaded()} keyed={true}>
<div class={clsx('col-sm-6 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()}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}> <div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
@ -129,7 +129,7 @@ export const HeaderAuth = (props: Props) => {
<Show when={isNotificationsVisible()}> <Show when={isNotificationsVisible()}>
<div class={styles.userControlItem} onClick={handleBellIconClick}> <div class={styles.userControlItem} onClick={handleBellIconClick}>
{/*TODO: check markup (cursor: pointer, hover)*/} <div class={styles.button}>
<Icon name="bell-white" counter={unreadNotificationsCount()} class={styles.icon} /> <Icon name="bell-white" counter={unreadNotificationsCount()} class={styles.icon} />
<Icon <Icon
name="bell-white-hover" name="bell-white-hover"
@ -137,6 +137,7 @@ export const HeaderAuth = (props: Props) => {
class={clsx(styles.icon, styles.iconHover)} class={clsx(styles.icon, styles.iconHover)}
/> />
</div> </div>
</div>
</Show> </Show>
<Show when={isSaveButtonVisible()}> <Show when={isSaveButtonVisible()}>
@ -175,7 +176,7 @@ export const HeaderAuth = (props: Props) => {
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}> <div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}>
<a href="?modal=auth&mode=login"> <a href="?modal=auth&mode=login">
<span class={styles.textLabel}>{t('Enter')}</span> <span class={styles.textLabel}>{t('Enter')}</span>
<Icon name="user-default" class={styles.icon} /> <Icon name="key" class={styles.icon} />
{/*<Icon name="user-default" class={clsx(styles.icon, styles.iconHover)} />*/} {/*<Icon name="user-default" class={clsx(styles.icon, styles.iconHover)} />*/}
</a> </a>
</div> </div>

View File

@ -71,8 +71,8 @@
} }
.close { .close {
right: 3.6rem; right: 1.6rem;
top: 12px; top: 1.6rem;
} }
} }
} }
@ -82,6 +82,7 @@
overflow: auto; overflow: auto;
padding: 5.4rem 2.4rem 2.4rem; padding: 5.4rem 2.4rem 2.4rem;
position: relative; position: relative;
text-align: left;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
padding: 5rem; padding: 5rem;

View File

@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, on, Show } from 'solid-js' import { createEffect, createMemo, createSignal, Show } from 'solid-js'
import type { JSX } from 'solid-js' import type { JSX } from 'solid-js'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { hideModal, useModalStore } from '../../../stores/ui' import { hideModal, useModalStore } from '../../../stores/ui'
@ -8,7 +8,6 @@ import styles from './Modal.module.scss'
import { redirectPage } from '@nanostores/router' import { redirectPage } from '@nanostores/router'
import { router } from '../../../stores/router' import { router } from '../../../stores/router'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { resetSortedArticles } from '../../../stores/zine/articles'
interface Props { interface Props {
name: string name: string

View File

@ -1,6 +1,5 @@
.Topics { .Topics {
@include font-size(1.4rem); font-size: 1.6rem;
height: 6rem; height: 6rem;
line-height: 6rem; line-height: 6rem;
margin-bottom: 3rem; margin-bottom: 3rem;
@ -12,32 +11,68 @@
padding: 0 divide($container-padding-x, 2); padding: 0 divide($container-padding-x, 2);
} }
@include media-breakpoint-up(md) {
@include font-size(1.4rem);
}
.list { .list {
display: flex; display: flex;
flex-wrap: wrap;
font-weight: 500; font-weight: 500;
list-style: none; list-style: none;
margin-top: 0; margin-top: 0;
padding: 0 7em 0 0; overflow: auto;
padding: 0;
position: relative; position: relative;
@include media-breakpoint-up(lg) {
flex-wrap: wrap;
overflow: hidden;
padding-right: 7em;
}
} }
.item { .item {
margin-right: 2.4rem; margin-right: 2.4rem;
&.right { &.right {
display: none;
margin-right: 0; margin-right: 0;
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
white-space: nowrap;
@include media-breakpoint-up(lg) {
display: block;
}
:global(.icon) {
display: inline-block;
margin-left: 0.3em;
top: 0.15em;
}
} }
a { a {
border-bottom: unset; border-bottom: unset;
&.selected { &.selected {
font-weight: 500; font-weight: 500;
border-bottom: 2px solid var(--default-color); border-bottom: 2px solid var(--default-color);
} }
&:hover {
.icon {
filter: invert(1);
}
}
}
.icon {
display: inline-block;
margin-left: 0.3em;
position: relative;
top: 0.15em;
} }
} }
} }

View File

@ -0,0 +1,12 @@
.EmptyMessage {
// TODO: check markup
color: var(--black-500);
text-align: center;
font-size: 15px;
line-height: 24px;
white-space: pre-line;
}
.title {
font-weight: 500;
}

View File

@ -0,0 +1,14 @@
import { clsx } from 'clsx'
import styles from './EmptyMessage.module.scss'
import { useLocalize } from '../../../context/localize'
export const EmptyMessage = () => {
const { t } = useLocalize()
return (
<div class={clsx(styles.EmptyMessage)}>
<div class={styles.title}>{t('No notifications yet')}</div>
<div>{t("Write good articles, comment\nand it won't be so empty here")}</div>
</div>
)
}

View File

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

View File

@ -20,6 +20,13 @@
&:hover { &:hover {
background-color: var(--gray-100); background-color: var(--gray-100);
} }
a,
a:visited {
padding-bottom: 0 !important;
border-bottom: none !important;
font-weight: 700;
}
} }
.userpic { .userpic {

View File

@ -1,15 +1,14 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import styles from './NotificationView.module.scss' import styles from './NotificationView.module.scss'
import type { Notification } from '../../../graphql/types.gen' import type { Notification } from '../../../graphql/types.gen'
import { formatDate } from '../../../utils'
import { createMemo, createSignal, onMount, Show } from 'solid-js' import { createMemo, createSignal, onMount, Show } from 'solid-js'
import { NotificationType } from '../../../graphql/types.gen' import { NotificationType } from '../../../graphql/types.gen'
import { openPage } from '@nanostores/router' import { getPagePath, openPage } from '@nanostores/router'
import { router } from '../../../stores/router' import { router, useRouter } from '../../../stores/router'
import { useNotifications } from '../../../context/notifications' import { useNotifications } from '../../../context/notifications'
import { Userpic } from '../../Author/Userpic' import { Userpic } from '../../Author/Userpic'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import notifications from '../../../graphql/query/notifications' import type { ArticlePageSearchParams } from '../../Article/FullArticle'
type Props = { type Props = {
notification: Notification notification: Notification
@ -28,13 +27,16 @@ type NotificationData = {
slug: string slug: string
userpic: string userpic: string
}[] }[]
reactionIds: number[]
} }
export const NotificationView = (props: Props) => { export const NotificationView = (props: Props) => {
const { const {
actions: { markNotificationAsRead } actions: { markNotificationAsRead, hideNotificationsPanel }
} = useNotifications() } = useNotifications()
const { changeSearchParam } = useRouter<ArticlePageSearchParams>()
const { t } = useLocalize() const { t } = useLocalize()
const [data, setData] = createSignal<NotificationData>(null) const [data, setData] = createSignal<NotificationData>(null)
@ -51,6 +53,11 @@ export const NotificationView = (props: Props) => {
return data().users[data().users.length - 1] return data().users[data().users.length - 1]
}) })
const handleLinkClick = (event: MouseEvent) => {
event.stopPropagation()
hideNotificationsPanel()
}
const content = createMemo(() => { const content = createMemo(() => {
if (!data()) { if (!data()) {
return null return null
@ -66,47 +73,67 @@ export const NotificationView = (props: Props) => {
} }
if (shoutTitle.length < data().shout.title.length) { if (shoutTitle.length < data().shout.title.length) {
shoutTitle += '...' shoutTitle = `${shoutTitle.trim()}...`
if (shoutTitle[0] === '«') {
shoutTitle += '»'
}
} }
switch (props.notification.type) { switch (props.notification.type) {
case NotificationType.NewComment: { case NotificationType.NewComment: {
return t('NewCommentNotificationText', { return (
commentsCount: props.notification.occurrences, <>
shoutTitle, {t('NotificationNewCommentText1', {
lastCommenterName: lastUser().name, commentsCount: props.notification.occurrences
})}{' '}
<a href={getPagePath(router, 'article', { slug: data().shout.slug })} onClick={handleLinkClick}>
{shoutTitle}
</a>{' '}
{t('NotificationNewCommentText2')}{' '}
<a href={getPagePath(router, 'author', { slug: lastUser().slug })} onClick={handleLinkClick}>
{lastUser().name}
</a>{' '}
{t('NotificationNewCommentText3', {
restUsersCount: data().users.length - 1 restUsersCount: data().users.length - 1
}) })}
</>
)
} }
case NotificationType.NewReply: { case NotificationType.NewReply: {
return t('NewReplyNotificationText', { return (
commentsCount: props.notification.occurrences, <>
shoutTitle, {t('NotificationNewReplyText1', {
lastCommenterName: lastUser().name, commentsCount: props.notification.occurrences
})}{' '}
<a href={getPagePath(router, 'article', { slug: data().shout.slug })} onClick={handleLinkClick}>
{shoutTitle}
</a>{' '}
{t('NotificationNewReplyText2')}{' '}
<a href={getPagePath(router, 'author', { slug: lastUser().slug })} onClick={handleLinkClick}>
{lastUser().name}
</a>{' '}
{t('NotificationNewReplyText3', {
restUsersCount: data().users.length - 1 restUsersCount: data().users.length - 1
}) })}
</>
)
} }
} }
}) })
const handleClick = () => { const handleClick = () => {
props.onClick()
if (!props.notification.seen) { if (!props.notification.seen) {
markNotificationAsRead(props.notification) markNotificationAsRead(props.notification)
} }
openPage(router, 'article', { slug: data().shout.slug }) openPage(router, 'article', { slug: data().shout.slug })
props.onClick()
// switch (props.notification.type) { if (data().reactionIds) {
// case NotificationType.NewComment: { changeSearchParam({ commentId: data().reactionIds[0].toString() })
// openPage(router, 'article', { slug: data().shout.slug }) }
// break
// }
// case NotificationType.NewReply: {
// openPage(router, 'article', { slug: data().shout.slug })
// break
// }
// }
} }
return ( return (

View File

@ -4,9 +4,10 @@ import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler'
import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler' import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { createEffect, For, onCleanup, onMount } from 'solid-js' import { createEffect, For } from 'solid-js'
import { useNotifications } from '../../context/notifications' import { useNotifications } from '../../context/notifications'
import { NotificationView } from './NotificationView' import { NotificationView } from './NotificationView'
import { EmptyMessage } from './EmptyMessage'
type Props = { type Props = {
isOpen: boolean isOpen: boolean
@ -30,8 +31,22 @@ export const NotificationsPanel = (props: Props) => {
handler: () => handleHide() handler: () => handleHide()
}) })
let windowScrollTop = 0
createEffect(() => { createEffect(() => {
const mainContent = document.querySelector<HTMLDivElement>('.main-content')
if (props.isOpen) {
windowScrollTop = window.scrollY
mainContent.style.marginTop = `-${windowScrollTop}px`
}
document.body.classList.toggle('fixed', props.isOpen) document.body.classList.toggle('fixed', props.isOpen)
if (!props.isOpen) {
mainContent.style.marginTop = ''
window.scrollTo(0, windowScrollTop)
}
}) })
useEscKeyDownHandler(handleHide) useEscKeyDownHandler(handleHide)
@ -52,10 +67,7 @@ export const NotificationsPanel = (props: Props) => {
<Icon name="close" /> <Icon name="close" />
</div> </div>
<div class={styles.title}>{t('Notifications')}</div> <div class={styles.title}>{t('Notifications')}</div>
<For <For each={sortedNotifications()} fallback={<EmptyMessage />}>
each={sortedNotifications()}
fallback={<div class={styles.emptyMessageContainer}>{t('No notifications, yet')}</div>}
>
{(notification) => ( {(notification) => (
<NotificationView <NotificationView
notification={notification} notification={notification}

View File

@ -158,12 +158,17 @@
font-size: 14px; font-size: 14px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
line-height: 20px; line-height: 1.8rem;
text-align: left; text-align: left;
vertical-align: bottom;
&:hover { &:hover {
color: rgb(0 0 0 / 50%); color: rgb(0 0 0 / 50%);
} }
&.active {
font-weight: 700 !important;
}
} }
.TableOfContentsHeadingsItemH3, .TableOfContentsHeadingsItemH3,

View File

@ -1,16 +1,12 @@
import { For, Show, createSignal, createEffect, on } from 'solid-js' import { For, Show, createSignal, createEffect, on, onMount, onCleanup } from 'solid-js'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { DEFAULT_HEADER_OFFSET } from '../../stores/router' import { DEFAULT_HEADER_OFFSET } from '../../stores/router'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import debounce from 'debounce' import debounce from 'debounce'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import styles from './TableOfContents.module.scss' import styles from './TableOfContents.module.scss'
import { isDesktop } from '../../utils/media-query' import { isDesktop } from '../../utils/media-query'
import throttle from 'just-throttle'
interface Props { interface Props {
variant: 'article' | 'editor' variant: 'article' | 'editor'
@ -18,6 +14,10 @@ interface Props {
body: string body: string
} }
const isInViewport = (el: Element): boolean => {
const rect = el.getBoundingClientRect()
return rect.top <= DEFAULT_HEADER_OFFSET
}
const scrollToHeader = (element) => { const scrollToHeader = (element) => {
window.scrollTo({ window.scrollTo({
behavior: 'smooth', behavior: 'smooth',
@ -31,9 +31,9 @@ const scrollToHeader = (element) => {
export const TableOfContents = (props: Props) => { export const TableOfContents = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const [headings, setHeadings] = createSignal<Element[]>([]) const [headings, setHeadings] = createSignal<HTMLElement[]>([])
const [areHeadingsLoaded, setAreHeadingsLoaded] = createSignal<boolean>(false) const [areHeadingsLoaded, setAreHeadingsLoaded] = createSignal<boolean>(false)
const [activeHeaderIndex, setActiveHeaderIndex] = createSignal<number>(-1)
const [isVisible, setIsVisible] = createSignal<boolean>(props.variant === 'article') const [isVisible, setIsVisible] = createSignal<boolean>(props.variant === 'article')
const toggleIsVisible = () => { const toggleIsVisible = () => {
setIsVisible((visible) => !visible) setIsVisible((visible) => !visible)
@ -42,15 +42,20 @@ export const TableOfContents = (props: Props) => {
setIsVisible(isDesktop()) setIsVisible(isDesktop())
const updateHeadings = () => { const updateHeadings = () => {
const { parentSelector } = props setHeadings(
// eslint-disable-next-line unicorn/prefer-spread // eslint-disable-next-line unicorn/prefer-spread
setHeadings(Array.from(document.querySelector(parentSelector).querySelectorAll('h2, h3, h4'))) Array.from(document.querySelector(props.parentSelector).querySelectorAll<HTMLElement>('h2, h3, h4'))
)
setAreHeadingsLoaded(true) setAreHeadingsLoaded(true)
} }
const debouncedUpdateHeadings = debounce(updateHeadings, 500) const debouncedUpdateHeadings = debounce(updateHeadings, 500)
const updateActiveHeader = throttle(() => {
const newActiveIndex = headings().findLastIndex((heading) => isInViewport(heading))
setActiveHeaderIndex(newActiveIndex)
}, 50)
createEffect( createEffect(
on( on(
() => props.body, () => props.body,
@ -58,6 +63,11 @@ export const TableOfContents = (props: Props) => {
) )
) )
onMount(() => {
window.addEventListener('scroll', updateActiveHeader)
onCleanup(() => window.removeEventListener('scroll', updateActiveHeader))
})
return ( return (
<Show <Show
when={ when={
@ -77,17 +87,17 @@ export const TableOfContents = (props: Props) => {
</div> </div>
<ul class={styles.TableOfContentsHeadingsList}> <ul class={styles.TableOfContentsHeadingsList}>
<For each={headings()}> <For each={headings()}>
{(h) => ( {(h, index) => (
<li> <li>
<button <button
class={clsx(styles.TableOfContentsHeadingsItem, { class={clsx(styles.TableOfContentsHeadingsItem, {
[styles.TableOfContentsHeadingsItemH3]: h.nodeName === 'H3', [styles.TableOfContentsHeadingsItemH3]: h.nodeName === 'H3',
[styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4' [styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4',
[styles.active]: index() === activeHeaderIndex()
})} })}
innerHTML={h.textContent} innerHTML={h.textContent}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
scrollToHeader(h) scrollToHeader(h)
}} }}
/> />

View File

@ -9,6 +9,10 @@
margin-bottom: 3rem; margin-bottom: 3rem;
} }
@include media-breakpoint-down(md) {
text-align: left;
}
.picture { .picture {
display: block; display: block;
width: 40px; width: 40px;

View File

@ -8,8 +8,12 @@
} }
.groupControls { .groupControls {
margin-bottom: 6rem !important; margin-bottom: 2rem !important;
margin-top: 0 !important; margin-top: 0 !important;
@include media-breakpoint-up(md) {
margin-bottom: 6rem !important;
}
} }
} }

View File

@ -16,7 +16,6 @@ import { apiClient } from '../../../utils/apiClient'
import { Comment } from '../../Article/Comment' import { Comment } from '../../Article/Comment'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { AuthorRatingControl } from '../../Author/AuthorRatingControl' import { AuthorRatingControl } from '../../Author/AuthorRatingControl'
import { hideModal } from '../../../stores/ui'
import { getPagePath } from '@nanostores/router' import { getPagePath } from '@nanostores/router'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { Loading } from '../../_shared/Loading' import { Loading } from '../../_shared/Loading'

View File

@ -1,5 +1,6 @@
.feedFilter { .feedFilter {
margin: 0.2em 0 4.8rem; margin-bottom: 4.8rem;
margin-top: 0.2em;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
margin-right: 4rem !important; margin-right: 4rem !important;

View File

@ -1,6 +1,6 @@
.button { .button {
border-radius: 2px; border-radius: 2px;
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 500; font-weight: 500;
@ -31,6 +31,17 @@
} }
} }
&.danger {
border: 3px solid var(--danger-color);
background: var(--background-color);
color: var(--danger-color);
&:hover {
background: var(--danger-color);
color: #fff;
}
}
&.inline { &.inline {
font-weight: 700; font-weight: 700;
font-size: 16px; font-size: 16px;

View File

@ -2,10 +2,11 @@ import type { JSX } from 'solid-js'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import styles from './Button.module.scss' import styles from './Button.module.scss'
export type ButtonVariant = 'primary' | 'secondary' | 'bordered' | 'inline' | 'light' | 'outline' | 'danger'
type Props = { type Props = {
value: string | JSX.Element value: string | JSX.Element
size?: 'S' | 'M' | 'L' size?: 'S' | 'M' | 'L'
variant?: 'primary' | 'secondary' | 'bordered' | 'inline' | 'light' | 'outline' variant?: ButtonVariant
type?: 'submit' | 'button' type?: 'submit' | 'button'
loading?: boolean loading?: boolean
disabled?: boolean disabled?: boolean

View File

@ -2,11 +2,14 @@ import { createContext, createSignal, useContext } from 'solid-js'
import type { Accessor, JSX } from 'solid-js' import type { Accessor, JSX } from 'solid-js'
import { hideModal, showModal } from '../stores/ui' import { hideModal, showModal } from '../stores/ui'
import { ButtonVariant } from '../components/_shared/Button/Button'
type ConfirmMessage = { type ConfirmMessage = {
confirmBody?: string | JSX.Element confirmBody?: string | JSX.Element
confirmButtonLabel?: string confirmButtonLabel?: string
confirmButtonVariant?: ButtonVariant
declineButtonLabel?: string declineButtonLabel?: string
declineButtonVariant?: ButtonVariant
} }
type ConfirmContextType = { type ConfirmContextType = {
@ -15,7 +18,9 @@ type ConfirmContextType = {
showConfirm: (message?: { showConfirm: (message?: {
confirmBody?: ConfirmMessage['confirmBody'] confirmBody?: ConfirmMessage['confirmBody']
confirmButtonLabel?: ConfirmMessage['confirmButtonLabel'] confirmButtonLabel?: ConfirmMessage['confirmButtonLabel']
confirmButtonVariant?: ConfirmMessage['confirmButtonVariant']
declineButtonLabel?: ConfirmMessage['declineButtonLabel'] declineButtonLabel?: ConfirmMessage['declineButtonLabel']
declineButtonVariant?: ConfirmMessage['declineButtonVariant']
}) => Promise<boolean> }) => Promise<boolean>
resolveConfirm: (value: boolean) => void resolveConfirm: (value: boolean) => void
} }
@ -36,13 +41,17 @@ export const ConfirmProvider = (props: { children: JSX.Element }) => {
message: { message: {
confirmBody?: ConfirmMessage['confirmBody'] confirmBody?: ConfirmMessage['confirmBody']
confirmButtonLabel?: ConfirmMessage['confirmButtonLabel'] confirmButtonLabel?: ConfirmMessage['confirmButtonLabel']
confirmButtonVariant?: ConfirmMessage['confirmButtonVariant']
declineButtonLabel?: ConfirmMessage['declineButtonLabel'] declineButtonLabel?: ConfirmMessage['declineButtonLabel']
declineButtonVariant?: ConfirmMessage['declineButtonVariant']
} = {} } = {}
): Promise<boolean> => { ): Promise<boolean> => {
const messageToShow = { const messageToShow = {
confirmBody: message.confirmBody, confirmBody: message.confirmBody,
confirmButtonLabel: message.confirmButtonLabel, confirmButtonLabel: message.confirmButtonLabel,
declineButtonLabel: message.declineButtonLabel confirmButtonVariant: message.confirmButtonVariant,
declineButtonLabel: message.declineButtonLabel,
declineButtonVariant: message.declineButtonVariant
} }
setConfirmMessage(messageToShow) setConfirmMessage(messageToShow)

View File

@ -16,6 +16,7 @@ type NotificationsContextType = {
sortedNotifications: Accessor<Notification[]> sortedNotifications: Accessor<Notification[]>
actions: { actions: {
showNotificationsPanel: () => void showNotificationsPanel: () => void
hideNotificationsPanel: () => void
markNotificationAsRead: (notification: Notification) => Promise<void> markNotificationAsRead: (notification: Notification) => Promise<void>
} }
} }
@ -80,7 +81,11 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
setIsNotificationsPanelOpen(true) setIsNotificationsPanelOpen(true)
} }
const actions = { showNotificationsPanel, markNotificationAsRead } const hideNotificationsPanel = () => {
setIsNotificationsPanelOpen(false)
}
const actions = { showNotificationsPanel, hideNotificationsPanel, markNotificationAsRead }
const value: NotificationsContextType = { const value: NotificationsContextType = {
notificationEntities, notificationEntities,

View File

@ -11,18 +11,9 @@ import { setPageLoadManagerPromise } from '../utils/pageLoadManager'
export const ArticlePage = (props: PageProps) => { export const ArticlePage = (props: PageProps) => {
const shouts = props.article ? [props.article] : [] const shouts = props.article ? [props.article] : []
const { page } = useRouter()
const slug = createMemo(() => { const slug = createMemo(() => page().params['slug'] as string)
const { page: getPage } = useRouter()
const page = getPage()
if (page.route !== 'article') {
throw new Error('ts guard')
}
return page.params.slug
})
const { articleEntities } = useArticlesStore({ const { articleEntities } = useArticlesStore({
shouts shouts

View File

@ -289,12 +289,16 @@ button {
.button--light { .button--light {
@include font-size(1.5rem); @include font-size(1.5rem);
background-color: #f6f6f6; background-color: #f6f6f6;
border-radius: 0.8rem;
color: #000; color: #000;
font-weight: 400; font-weight: 500;
height: auto; height: auto;
padding: 0.6rem 1.2rem 0.6rem 1rem; padding: 0.6rem 1.2rem 0.6rem 1rem;
&:hover {
background: #e9e9ee;
}
} }
.button--subscribe-topic { .button--subscribe-topic {
@ -589,18 +593,22 @@ figure {
.view-switcher { .view-switcher {
@include font-size(1.4rem); @include font-size(1.4rem);
display: flex; display: flex;
flex-wrap: wrap;
font-weight: 500; font-weight: 500;
list-style: none; list-style: none;
margin: 3.6rem 0 0; margin: 3.6rem -1rem 0;
padding: 0; overflow: auto;
padding: 0 1rem;
@include media-breakpoint-up(md) {
flex-wrap: wrap;
}
li { li {
display: inline-block; display: inline-block;
margin-right: 2rem; margin-right: 2rem;
margin-bottom: 0.6em; margin-bottom: 0.6em;
white-space: nowrap;
&:last-child { &:last-child {
margin-right: 0; margin-right: 0;

View File

@ -13,6 +13,6 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"isolatedModules": true, "isolatedModules": true,
"lib": ["ES2021", "dom"] "lib": ["es2023", "dom"]
} }
} }