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-icu": "2.3.0",
"intl-messageformat": "10.5.3",
"just-throttle": "4.2.0",
"mailgun.js": "8.2.1",
"node-fetch": "3.3.1"
},
@ -13168,6 +13169,11 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz",
@ -28106,6 +28112,11 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz",

View File

@ -33,6 +33,7 @@
"i18next": "22.4.15",
"i18next-icu": "2.3.0",
"intl-messageformat": "10.5.3",
"just-throttle": "4.2.0",
"mailgun.js": "8.2.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 topics": "All topics",
"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?",
"Art": "Art",
"Artist": "Artist",
@ -100,6 +102,7 @@
"Discussion rules": "Discussion rules",
"Discussions": "Discussions",
"Dogma": "Dogma",
"Draft successfully deleted": "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.": "Each image must be no larger than 5 MB.",
@ -193,6 +196,7 @@
"Manifesto": "Manifesto",
"Many files, choose only one": "Many files, choose only one",
"Material card": "Material card",
"Message": "Message",
"More": "More",
"Most commented": "Commented",
"Most read": "Readable",
@ -206,12 +210,19 @@
"New only": "New only",
"New password": "New password",
"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",
"Night mode": "Night mode",
"No notifications, yet": "No notifications, yet",
"No such account, please try to register": "No such account found, please try to register",
"No notifications yet": "No notifications yet",
"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 is here": "There is nothing here",
"Notifications": "Notifications",
@ -353,7 +364,6 @@
"Where": "From",
"Words": "Слов",
"Work with us": "Cooperate with Discourse",
"Message": "Message",
"Write a comment...": "Write a comment...",
"Write a short introduction": "Write a short introduction",
"Write about the topic": "Write about the topic",

View File

@ -31,6 +31,8 @@
"All posts": "Все публикации",
"All topics": "Все темы",
"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?": "Вы уверены, что хотите продолжить?",
"Art": "Искусство",
"Artist": "Исполнитель",
@ -104,6 +106,7 @@
"Discussion rules": "Правила сообществ самиздата в&nbsp;соцсетях",
"Discussions": "Дискуссии",
"Dogma": "Догма",
"Draft successfully deleted": "Черновик успешно удален",
"Drafts": "Черновики",
"Drag the image to this area": "Перетащите изображение в эту область",
"Each image must be no larger than 5 MB.": "Каждое изображение должно быть размером не больше 5 мб.",
@ -217,11 +220,19 @@
"New only": "Только новые",
"New password": "Новый пароль",
"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": "Рассылка",
"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": "Такой адрес не найден, попробуйте зарегистрироваться",
"Nothing here yet": "Здесь пока ничего нет",
"Nothing is here": "Здесь ничего нет",

View File

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

View File

@ -62,7 +62,12 @@ export const Comment = (props: Props) => {
const remove = async () => {
if (comment()?.id) {
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) {
await deleteReaction(comment().id)
@ -136,7 +141,7 @@ export const Comment = (props: Props) => {
})}
/>
<small>
<a href={`#comment-${comment()?.id}`}>{comment()?.shout.title || ''}</a>
<a href={`#comment_${comment()?.id}`}>{comment()?.shout.title || ''}</a>
</small>
</div>
}
@ -174,7 +179,7 @@ export const Comment = (props: Props) => {
<CommentRatingControl comment={comment()} />
</div>
</Show>
<div class={styles.commentBody} id={'comment-' + (comment().id || '')}>
<div class={styles.commentBody}>
<Show when={editMode()} fallback={<MD body={body()} />}>
<Suspense fallback={<p>{t('Loading')}</p>}>
<SimplifiedEditor

View File

@ -4,14 +4,15 @@
align-self: center;
display: flex;
flex: 1;
flex-wrap: wrap;
@include font-size(1.2rem);
font-size: 1.2rem;
gap: 1rem;
justify-content: flex-start;
margin: 0 1em 0 0;
.date {
font-weight: 500;
margin-right: 1rem;
.icon {
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 styles from './Article.module.scss'
import { clsx } from 'clsx'
@ -181,7 +181,7 @@ export const CommentsTree = (props: Props) => {
<SimplifiedEditor
quoteEnabled={true}
imageEnabled={true}
autoFocus={true}
autoFocus={false}
submitByCtrlEnter={true}
placeholder={t('Write a comment...')}
onSubmit={(value) => handleSubmitComment(value)}

View File

@ -28,11 +28,26 @@ import styles from './Article.module.scss'
import { CardTopic } from '../Feed/CardTopic'
import { createPopper } from '@popperjs/core'
interface Props {
type Props = {
article: Shout
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) => {
const { t } = useLocalize()
const {
@ -77,16 +92,15 @@ export const FullArticle = (props: Props) => {
return JSON.parse(props.article.media || '[]')
})
const commentsRef: { current: HTMLDivElement } = { current: null }
const commentsRef: {
current: HTMLDivElement
} = { current: null }
const scrollToComments = () => {
window.scrollTo({
top: commentsRef.current.offsetTop - 96,
left: 0,
behavior: 'smooth'
})
scrollTo(commentsRef.current)
}
const { searchParams, changeSearchParam } = useRouter()
const { searchParams, changeSearchParam } = useRouter<ArticlePageSearchParams>()
createEffect(() => {
if (props.scrollToComments) {
@ -105,9 +119,14 @@ export const FullArticle = (props: Props) => {
createEffect(() => {
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) {
commentElement.scrollIntoView({ behavior: 'smooth' })
scrollTo(commentElement)
}
}
})

View File

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

View File

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

View File

@ -208,8 +208,7 @@ export const AuthorCard = (props: Props) => {
}
>
<div class={styles.subscribersContainer}>
<Switch>
<Match when={props.followers && props.followers.length > 0 && !props.isCurrentUser}>
<Show when={props.followers && props.followers.length > 0}>
<a href="?modal=followers" class={styles.subscribers}>
<For each={props.followers.slice(0, 3)}>
{(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 })}
</div>
</a>
</Match>
<Match when={props.followers && props.followers.length > 0 && props.isCurrentUser}>
<Button
variant="secondary"
onClick={() => redirectPage(router, 'profileSettings')}
value={t('Edit profile')}
/>
</Match>
</Switch>
</Show>
<Switch>
<Match when={!props.isCurrentUser && props.following && props.following.length > 0}>
<Show when={props.following && props.following.length > 0}>
<a href="?modal=following" class={styles.subscribers}>
<For each={props.following.slice(0, 3)}>
{(f) => {
@ -245,23 +235,13 @@ export const AuthorCard = (props: Props) => {
{t('SubscriptionWithCount', { count: props?.following.length ?? 0 })}
</div>
</a>
</Match>
<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>
</Show>
</div>
</Show>
</div>
<ShowOnlyOnClient>
<Show when={isSessionLoaded() && props.author.links}>
<Show when={isSessionLoaded()}>
<Show when={props.author.links && props.author.links.length > 0}>
<div class={styles.authorSubscribeSocial}>
<For each={props.author.links}>
{(link) => (
@ -278,6 +258,7 @@ export const AuthorCard = (props: Props) => {
)}
</For>
</div>
</Show>
<Show when={canFollow()}>
<div class={styles.authorSubscribe}>
<Show
@ -346,9 +327,9 @@ export const AuthorCard = (props: Props) => {
<Show when={!props.hideWriteButton}>
<button
class={clsx(styles.button, styles.buttonSubscribe)}
class={styles.button}
classList={{
'button--subscribe': !props.isAuthorsList,
'button--light': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList,
[styles.buttonWrite]: props.liteButtons && props.isAuthorsList
}}
@ -362,6 +343,26 @@ export const AuthorCard = (props: Props) => {
</Show>
</div>
</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>
</ShowOnlyOnClient>
<Show when={props.followers}>

View File

@ -35,11 +35,16 @@ export const Draft = (props: Props) => {
const handleDeleteLinkClick = async (e) => {
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) {
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 {
@include font-size(1.4rem);
font-weight: 500;
margin-right: 1.6rem;
@ -139,9 +135,11 @@
.shoutDate {
color: #9fa1a7;
font-weight: 500;
@include font-size(1.2rem);
}
.shoutDetails {
align-items: end;
display: flex;
margin-bottom: 1rem;
}
@ -149,8 +147,7 @@
.shoutDetailsFeedMode {
justify-content: space-between;
.shoutAuthor,
.shoutDate {
.shoutAuthor {
@include font-size(1.2rem);
}
}
@ -194,19 +191,27 @@
font-weight: 400;
line-height: 1.3;
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 {
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 {
border-radius: 2em;
min-height: 2.6em;
padding: 0 1.4em;
transition: background-color 0.2s, color 0.2s;
transition:
background-color 0.2s,
color 0.2s;
&:hover {
background: var(--background-color-invert);

View File

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

View File

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

View File

@ -1,48 +1,22 @@
.confirmModal {
background: #fff;
min-height: 550px;
position: relative;
@include media-breakpoint-up(md) {
min-height: 710px;
}
}
.confirmModalTitle {
@include font-size(2rem);
.confirmModalTitle {
font-size: 26px;
line-height: 32px;
font-weight: 700;
color: #141414;
text-align: left;
}
color: var(--default-color);
text-align: center;
}
.confirmModalActions {
.confirmModalActions {
display: flex;
justify-content: space-between;
margin-top: 16px;
}
margin-top: 4rem;
gap: 2rem;
.confirmModalButton {
display: block;
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%);
.confirmAction {
flex: 1;
}
}
}

View File

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

View File

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

View File

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

View File

@ -108,7 +108,7 @@ export const HeaderAuth = (props: Props) => {
return (
<ShowOnlyOnClient>
<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}>
<Show when={isCreatePostButtonVisible()}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
@ -129,7 +129,7 @@ export const HeaderAuth = (props: Props) => {
<Show when={isNotificationsVisible()}>
<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-hover"
@ -137,6 +137,7 @@ export const HeaderAuth = (props: Props) => {
class={clsx(styles.icon, styles.iconHover)}
/>
</div>
</div>
</Show>
<Show when={isSaveButtonVisible()}>
@ -175,7 +176,7 @@ export const HeaderAuth = (props: Props) => {
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}>
<a href="?modal=auth&mode=login">
<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)} />*/}
</a>
</div>

View File

@ -71,8 +71,8 @@
}
.close {
right: 3.6rem;
top: 12px;
right: 1.6rem;
top: 1.6rem;
}
}
}
@ -82,6 +82,7 @@
overflow: auto;
padding: 5.4rem 2.4rem 2.4rem;
position: relative;
text-align: left;
@include media-breakpoint-up(sm) {
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 { clsx } from 'clsx'
import { hideModal, useModalStore } from '../../../stores/ui'
@ -8,7 +8,6 @@ import styles from './Modal.module.scss'
import { redirectPage } from '@nanostores/router'
import { router } from '../../../stores/router'
import { Icon } from '../../_shared/Icon'
import { resetSortedArticles } from '../../../stores/zine/articles'
interface Props {
name: string

View File

@ -1,6 +1,5 @@
.Topics {
@include font-size(1.4rem);
font-size: 1.6rem;
height: 6rem;
line-height: 6rem;
margin-bottom: 3rem;
@ -12,32 +11,68 @@
padding: 0 divide($container-padding-x, 2);
}
@include media-breakpoint-up(md) {
@include font-size(1.4rem);
}
.list {
display: flex;
flex-wrap: wrap;
font-weight: 500;
list-style: none;
margin-top: 0;
padding: 0 7em 0 0;
overflow: auto;
padding: 0;
position: relative;
@include media-breakpoint-up(lg) {
flex-wrap: wrap;
overflow: hidden;
padding-right: 7em;
}
}
.item {
margin-right: 2.4rem;
&.right {
display: none;
margin-right: 0;
position: absolute;
right: 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 {
border-bottom: unset;
&.selected {
font-weight: 500;
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 {
background-color: var(--gray-100);
}
a,
a:visited {
padding-bottom: 0 !important;
border-bottom: none !important;
font-weight: 700;
}
}
.userpic {

View File

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

View File

@ -4,9 +4,10 @@ import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler'
import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler'
import { useLocalize } from '../../context/localize'
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 { NotificationView } from './NotificationView'
import { EmptyMessage } from './EmptyMessage'
type Props = {
isOpen: boolean
@ -30,8 +31,22 @@ export const NotificationsPanel = (props: Props) => {
handler: () => handleHide()
})
let windowScrollTop = 0
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)
if (!props.isOpen) {
mainContent.style.marginTop = ''
window.scrollTo(0, windowScrollTop)
}
})
useEscKeyDownHandler(handleHide)
@ -52,10 +67,7 @@ export const NotificationsPanel = (props: Props) => {
<Icon name="close" />
</div>
<div class={styles.title}>{t('Notifications')}</div>
<For
each={sortedNotifications()}
fallback={<div class={styles.emptyMessageContainer}>{t('No notifications, yet')}</div>}
>
<For each={sortedNotifications()} fallback={<EmptyMessage />}>
{(notification) => (
<NotificationView
notification={notification}

View File

@ -158,12 +158,17 @@
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
line-height: 1.8rem;
text-align: left;
vertical-align: bottom;
&:hover {
color: rgb(0 0 0 / 50%);
}
&.active {
font-weight: 700 !important;
}
}
.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 { DEFAULT_HEADER_OFFSET } from '../../stores/router'
import { useLocalize } from '../../context/localize'
import debounce from 'debounce'
import { Icon } from '../_shared/Icon'
import styles from './TableOfContents.module.scss'
import { isDesktop } from '../../utils/media-query'
import throttle from 'just-throttle'
interface Props {
variant: 'article' | 'editor'
@ -18,6 +14,10 @@ interface Props {
body: string
}
const isInViewport = (el: Element): boolean => {
const rect = el.getBoundingClientRect()
return rect.top <= DEFAULT_HEADER_OFFSET
}
const scrollToHeader = (element) => {
window.scrollTo({
behavior: 'smooth',
@ -31,9 +31,9 @@ const scrollToHeader = (element) => {
export const TableOfContents = (props: Props) => {
const { t } = useLocalize()
const [headings, setHeadings] = createSignal<Element[]>([])
const [headings, setHeadings] = createSignal<HTMLElement[]>([])
const [areHeadingsLoaded, setAreHeadingsLoaded] = createSignal<boolean>(false)
const [activeHeaderIndex, setActiveHeaderIndex] = createSignal<number>(-1)
const [isVisible, setIsVisible] = createSignal<boolean>(props.variant === 'article')
const toggleIsVisible = () => {
setIsVisible((visible) => !visible)
@ -42,15 +42,20 @@ export const TableOfContents = (props: Props) => {
setIsVisible(isDesktop())
const updateHeadings = () => {
const { parentSelector } = props
setHeadings(
// 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)
}
const debouncedUpdateHeadings = debounce(updateHeadings, 500)
const updateActiveHeader = throttle(() => {
const newActiveIndex = headings().findLastIndex((heading) => isInViewport(heading))
setActiveHeaderIndex(newActiveIndex)
}, 50)
createEffect(
on(
() => props.body,
@ -58,6 +63,11 @@ export const TableOfContents = (props: Props) => {
)
)
onMount(() => {
window.addEventListener('scroll', updateActiveHeader)
onCleanup(() => window.removeEventListener('scroll', updateActiveHeader))
})
return (
<Show
when={
@ -77,17 +87,17 @@ export const TableOfContents = (props: Props) => {
</div>
<ul class={styles.TableOfContentsHeadingsList}>
<For each={headings()}>
{(h) => (
{(h, index) => (
<li>
<button
class={clsx(styles.TableOfContentsHeadingsItem, {
[styles.TableOfContentsHeadingsItemH3]: h.nodeName === 'H3',
[styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4'
[styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4',
[styles.active]: index() === activeHeaderIndex()
})}
innerHTML={h.textContent}
onClick={(e) => {
e.preventDefault()
scrollToHeader(h)
}}
/>

View File

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

View File

@ -8,8 +8,12 @@
}
.groupControls {
margin-bottom: 6rem !important;
margin-bottom: 2rem !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 { useLocalize } from '../../../context/localize'
import { AuthorRatingControl } from '../../Author/AuthorRatingControl'
import { hideModal } from '../../../stores/ui'
import { getPagePath } from '@nanostores/router'
import { useSession } from '../../../context/session'
import { Loading } from '../../_shared/Loading'

View File

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

View File

@ -1,6 +1,6 @@
.button {
border-radius: 2px;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
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 {
font-weight: 700;
font-size: 16px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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