This commit is contained in:
Untone 2023-10-14 18:41:22 +03:00
commit 26ba530f9e
46 changed files with 1169 additions and 739 deletions

View File

@ -211,6 +211,7 @@
"New stories every day and even more!": "New stories and more are waiting for you every day!",
"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",
"Nothing here yet": "There's nothing here yet",
"Nothing is here": "There is nothing here",
@ -244,6 +245,7 @@
"Profile": "Profile",
"Profile settings": "Profile settings",
"Publications": "Publications",
"PublicationsWithCount": "{count, plural, =0 {no publications} one {{count} publication} other {{count} publications}}",
"Publish Album": "Publish Album",
"Publish Settings": "Publish Settings",
"Punchline": "Punchline",
@ -413,5 +415,6 @@
"video": "video",
"view": "view",
"zine": "zine",
"PublicationsWithCount": "{count, plural, =0 {no publications} one {{count} publication} other {{count} publications}}"
"SubscriptionWithPlurals": "{count, plural, =0 {no subscriptions} one {{count} subscription} other {{count} subscriptions}",
"Edit profile": "Edit profile"
}

View File

@ -221,6 +221,7 @@
"New stories every day and even more!": "Каждый день вас ждут новые истории и ещё много всего интересного!",
"Newsletter": "Рассылка",
"Night mode": "Ночная тема",
"No notifications, yet": "Тут пока пусто",
"No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться",
"Nothing here yet": "Здесь пока ничего нет",
"Nothing is here": "Здесь ничего нет",
@ -257,6 +258,7 @@
"Profile successfully saved": "Профиль успешно сохранён",
"Publication settings": "Настройки публикации",
"Publications": "Публикации",
"PublicationsWithCount": "{count, plural, =0 {нет публикаций} one {{count} публикация} few {{count} публикации} other {{count} публикаций}}",
"Publish": "Опубликовать",
"Publish Album": "Опубликовать альбом",
"Publish Settings": "Настройки публикации",
@ -430,14 +432,15 @@
"subscriber": "подписчик",
"subscriber_rp": "подписчика",
"subscribers": "подписчиков",
"subscription": "подписка",
"subscription_rp": "подписки",
"subscriptions": "подписок",
"terms of use": "правилами пользования сайтом",
"topics": "темы",
"user already exist": "пользователь уже существует",
"video": "видео",
"view": "просмотр",
"zine": "журнал",
"PublicationsWithCount": "{count, plural, =0 {нет публикаций} one {{count} публикация} few {{count} публикации} other {{count} публикаций}}"
"SubscriberWithCount": "{count, plural, =0 {нет подписчиков} one {{count} подписчик} few {{count} подписчика} other {{count} подписчиков}}",
"SubscriptionWithCount": "{count, plural, =0 {нет подписок} one {{count} подписка} few {{count} подписки} other {{count} подписок}}",
"Edit profile": "Редактировать профиль",
"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} пользователей}}"
}

View File

@ -40,6 +40,7 @@ import { SnackbarProvider } from '../context/snackbar'
import { LocalizeProvider } from '../context/localize'
import { ConfirmProvider } from '../context/confirm'
import { EditorProvider } from '../context/editor'
import { NotificationsProvider } from '../context/notifications'
// TODO: lazy load
// const SomePage = lazy(() => import('./Pages/SomePage'))
@ -48,8 +49,6 @@ const pagesMap: Record<keyof typeof ROUTES, Component<PageProps>> = {
author: AuthorPage,
authorComments: AuthorPage,
authorAbout: AuthorPage,
authorFollowing: AuthorPage,
authorFollowers: AuthorPage,
inbox: InboxPage,
expo: ExpoPage,
expoLayout: ExpoPage,
@ -115,9 +114,11 @@ export const App = (props: PageProps) => {
<SnackbarProvider>
<ConfirmProvider>
<SessionProvider>
<NotificationsProvider>
<EditorProvider>
<Dynamic component={pageComponent()} {...props} />
</EditorProvider>
</NotificationsProvider>
</SessionProvider>
</ConfirmProvider>
</SnackbarProvider>

View File

@ -5,19 +5,6 @@
.albumInfo {
margin-right: 224px;
.topic {
.link {
@include font-size(1.6rem);
color: var(--blue-link);
border: none;
&:hover {
text-decoration: underline;
}
}
}
& > h1 {
margin: 16px 0 0;
}

View File

@ -5,8 +5,7 @@ import { MediaItem } from '../../../pages/types'
import { createSignal, Show } from 'solid-js'
import { Icon } from '../../_shared/Icon'
import { Topic } from '../../../graphql/types.gen'
import { getPagePath } from '@nanostores/router'
import { router } from '../../../stores/router'
import { CardTopic } from '../../Feed/CardTopic'
type Props = {
title: string
@ -29,11 +28,7 @@ export const AudioHeader = (props: Props) => {
</div>
<div class={styles.albumInfo}>
<Show when={props.topic}>
<div class={styles.topic}>
<a href={getPagePath(router, 'topic', { slug: props.topic.slug })} class={styles.link}>
{props.topic.title}
</a>
</div>
<CardTopic title={props.topic.title} slug={props.topic.slug} />
</Show>
<h1>{props.title}</h1>
<Show when={props.artistData}>

View File

@ -36,8 +36,9 @@ type Props = {
export const Comment = (props: Props) => {
const { t } = useLocalize()
const [isReplyVisible, setIsReplyVisible] = createSignal(false)
const [loading, setLoading] = createSignal<boolean>(false)
const [editMode, setEditMode] = createSignal<boolean>(false)
const [loading, setLoading] = createSignal(false)
const [editMode, setEditMode] = createSignal(false)
const [clearEditor, setClearEditor] = createSignal(false)
const { session } = useSession()
const {
@ -81,11 +82,13 @@ export const Comment = (props: Props) => {
body: value,
shout: props.comment.shout.id
})
setClearEditor(true)
setIsReplyVisible(false)
setLoading(false)
} catch (error) {
console.error('[handleCreate reaction]:', error)
}
setClearEditor(false)
}
const toggleEditMode = () => {
@ -175,11 +178,13 @@ export const Comment = (props: Props) => {
<SimplifiedEditor
initialContent={comment().body}
submitButtonText={t('Save')}
submitByEnter={true}
quoteEnabled={true}
imageEnabled={true}
placeholder={t('Write a comment...')}
onSubmit={(value) => handleUpdate(value)}
submitByShiftEnter={true}
setClear={clearEditor()}
/>
</Suspense>
</Show>

View File

@ -33,7 +33,7 @@ const sortCommentsByRating = (a: Reaction, b: Reaction): -1 | 0 | 1 => {
}
type Props = {
commentAuthors: Author[]
articleAuthors: Author[]
shoutSlug: string
shoutId: number
}
@ -149,7 +149,9 @@ export const CommentsTree = (props: Props) => {
{(reaction) => (
<Comment
sortedComments={sortedComments()}
isArticleAuthor={Boolean(props.commentAuthors.some((a) => a.slug === session()?.user.slug))}
isArticleAuthor={Boolean(
props.articleAuthors.some((a) => a.slug === reaction.createdBy.slug)
)}
comment={reaction}
lastSeen={dateFromLocalStorage}
/>

View File

@ -431,7 +431,7 @@ export const FullArticle = (props: Props) => {
<CommentsTree
shoutId={props.article.id}
shoutSlug={props.article.slug}
commentAuthors={props.article.authors}
articleAuthors={props.article.authors}
/>
</Show>
</div>

View File

@ -90,12 +90,14 @@
.authorName {
border: none !important;
display: block;
font-size: 1.6rem;
font-weight: 500;
margin-bottom: 0.8rem;
.listWrapper &:before {
.listWrapper & {
display: block;
&:before {
content: '';
height: 100%;
left: 0;
@ -104,6 +106,7 @@
width: 100%;
z-index: 2;
}
}
}
.authorAbout {
@ -121,7 +124,45 @@
flex-wrap: wrap;
}
a {
.button {
padding-left: 2rem;
padding-right: 2rem;
margin-right: 0.5em;
&:first-of-type {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
&:hover {
.buttonUnfollowLabel {
display: block;
}
.buttonSubscribedLabel {
display: none;
}
}
.buttonUnfollowLabel {
display: none;
}
}
}
.authorSubscribeSocialLabel {
display: none;
}
.authorSubscribeSocial {
align-items: center;
display: flex;
margin: 2rem 0;
.socialLink {
border: none;
display: inline-block;
height: 24px;
@ -162,9 +203,8 @@
position: absolute;
}
}
}
a[href*='facebook.com/'] {
&[href*='facebook.com/'] {
&::before {
background-image: url(/icons/user-link-facebook.svg);
}
@ -176,7 +216,7 @@
}
}
a[href*='twitter.com/'] {
&[href*='twitter.com/'] {
&::before {
background-image: url(/icons/user-link-twitter.svg);
}
@ -188,7 +228,7 @@
}
}
a[href*='telegram.com/'] {
&[href*='telegram.com/'] {
&::before {
background-image: url(/icons/user-link-telegram.svg);
}
@ -200,8 +240,8 @@
}
}
a[href*='vk.cc/'],
a[href*='vk.com/'] {
&[href*='vk.cc/'],
&[href*='vk.com/'] {
&::before {
background-image: url(/icons/user-link-vk.svg);
}
@ -213,7 +253,7 @@
}
}
a[href*='tumblr.com/'] {
&[href*='tumblr.com/'] {
&::before {
background-image: url(/icons/user-link-tumblr.svg);
}
@ -225,7 +265,7 @@
}
}
a[href*='instagram.com/'] {
&[href*='instagram.com/'] {
&::before {
background-image: url(/icons/user-link-instagram.svg);
}
@ -237,7 +277,7 @@
}
}
a[href*='behance.net/'] {
&[href*='behance.net/'] {
&::before {
background-image: url(/icons/user-link-behance.svg);
}
@ -249,7 +289,7 @@
}
}
a[href*='dribbble.com/'] {
&[href*='dribbble.com/'] {
&::before {
background-image: url(/icons/user-link-dribbble.svg);
}
@ -261,7 +301,7 @@
}
}
a[href*='github.com/'] {
&[href*='github.com/'] {
&::before {
background-image: url(/icons/user-link-github.svg);
}
@ -273,7 +313,7 @@
}
}
a[href*='linkedin.com/'] {
&[href*='linkedin.com/'] {
&::before {
background-image: url(/icons/user-link-linkedin.svg);
}
@ -285,7 +325,7 @@
}
}
a[href*='medium.com/'] {
&[href*='medium.com/'] {
&::before {
background-image: url(/icons/user-link-medium.svg);
}
@ -297,7 +337,7 @@
}
}
a[href*='ok.ru/'] {
&[href*='ok.ru/'] {
&::before {
background-image: url(/icons/user-link-ok.svg);
}
@ -309,7 +349,7 @@
}
}
a[href*='pinterest.com/'] {
&[href*='pinterest.com/'] {
&::before {
background-image: url(/icons/user-link-pinterest.svg);
}
@ -321,7 +361,7 @@
}
}
a[href*='reddit.com/'] {
&[href*='reddit.com/'] {
&::before {
background-image: url(/icons/user-link-reddit.svg);
}
@ -333,7 +373,7 @@
}
}
a[href*='tiktok.com/'] {
&[href*='tiktok.com/'] {
&::before {
background-image: url(/icons/user-link-tiktok.svg);
}
@ -345,8 +385,8 @@
}
}
a[href*='youtube.com/'],
a[href*='youtu.be/'] {
&[href*='youtube.com/'],
&[href*='youtu.be/'] {
&::before {
background-image: url(/icons/user-link-youtube.svg);
}
@ -358,7 +398,7 @@
}
}
a[href*='dzen.ru/'] {
&[href*='dzen.ru/'] {
&::before {
background-image: url(/icons/user-link-dzen.svg);
}
@ -369,45 +409,8 @@
}
}
}
.button {
padding-left: 2rem;
padding-right: 2rem;
margin-right: 0.5em;
&:first-of-type {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
&:hover {
.buttonUnfollowLabel {
display: block;
}
.buttonSubscribedLabel {
display: none;
}
}
.buttonUnfollowLabel {
display: none;
}
}
}
.authorSubscribeSocialLabel {
display: none;
}
.authorSubscribeSocial {
align-items: center;
display: flex;
margin: 0 0.8rem 2rem 0;
@include media-breakpoint-down(sm) {
flex: 1 100%;
justify-content: center;
@ -470,21 +473,6 @@
}
}
.buttonWriteAuthorPage {
background: #f6f6f6 !important;
border-radius: 0.8rem;
&:hover {
background: #e9e9ee !important;
border-color: #e9e9ee;
}
&:active {
background: #ccc !important;
border-color: #ccc;
}
}
.authorPage {
align-items: center;
@ -637,7 +625,8 @@
display: flex;
flex-wrap: wrap;
font-size: 1.4rem;
margin-top: 0.5rem;
margin-top: 1.5rem;
gap: 1rem;
@include media-breakpoint-down(md) {
justify-content: center;
@ -648,9 +637,9 @@
align-items: center;
cursor: pointer;
display: inline-flex;
margin-top: 1rem;
margin-right: 3rem;
vertical-align: top;
border-bottom: unset !important;
&:last-child {
margin-right: 0;
@ -679,10 +668,6 @@
.subscribersCounter {
font-weight: 500;
margin-left: -0.6rem;
&:hover {
color: #696969;
}
}
.listWrapper {

View File

@ -2,7 +2,7 @@ import type { Author } from '../../../graphql/types.gen'
import { Userpic } from '../Userpic'
import { Icon } from '../../_shared/Icon'
import styles from './AuthorCard.module.scss'
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from 'solid-js'
import { translit } from '../../../utils/ru2en'
import { follow, unfollow } from '../../../stores/zine/common'
import { clsx } from 'clsx'
@ -14,12 +14,13 @@ import { openPage, redirectPage } from '@nanostores/router'
import { useLocalize } from '../../../context/localize'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { Modal } from '../../Nav/Modal'
import { showModal } from '../../../stores/ui'
import { getNumeralsDeclension } from '../../../utils/getNumeralsDeclension'
import { SubscriptionFilter } from '../../../pages/types'
import { isAuthor } from '../../../utils/isAuthor'
import { AuthorBadge } from '../AuthorBadge'
import { TopicBadge } from '../../Topic/TopicBadge'
import { Button } from '../../_shared/Button'
import { getShareUrl, SharePopup } from '../../Article/SharePopup'
import stylesHeader from '../../Nav/Header/Header.module.scss'
type Props = {
caption?: string
@ -43,11 +44,11 @@ type Props = {
following?: Array<Author | Topic>
showPublicationsCounter?: boolean
hideBio?: boolean
isCurrentUser?: boolean
}
export const AuthorCard = (props: Props) => {
const { t, lang } = useLocalize()
const { page } = useRouter()
const {
session,
isSessionLoaded,
@ -117,12 +118,6 @@ export const AuthorCard = (props: Props) => {
}
})
createEffect(() => {
if (page().route === 'authorFollowing') {
showModal('following')
}
})
const handleCloseFollowModals = () => {
redirectPage(router, 'author', { slug: props.author.slug })
}
@ -130,9 +125,7 @@ export const AuthorCard = (props: Props) => {
if (props.isAuthorPage && props.author.userpic?.includes('assets.discours.io')) {
setUserpicUrl(props.author.userpic.replace('100x', '500x500'))
}
return (
<>
<div
class={clsx(styles.author, props.class)}
classList={{
@ -219,35 +212,28 @@ export const AuthorCard = (props: Props) => {
}
>
<div class={styles.subscribersContainer}>
<Show when={props.followers && props.followers.length > 0}>
<div
class={styles.subscribers}
onClick={() => {
redirectPage(router, 'authorFollowers', { slug: props.author.slug })
showModal('followers')
}}
>
<Switch>
<Match when={props.followers && props.followers.length > 0 && !props.isCurrentUser}>
<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} />}
</For>
<div class={styles.subscribersCounter}>
{props.followers.length}&nbsp;
{getNumeralsDeclension(props.followers.length, [
t('subscriber'),
t('subscriber_rp'),
t('subscribers')
])}
{t('SubscriptionWithCount', { count: props.followers.length })}
</div>
</div>
</Show>
</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 when={props.following && props.following.length > 0}>
<div
class={styles.subscribers}
onClick={() => {
redirectPage(router, 'authorFollowing', { slug: props.author.slug })
showModal('following')
}}
>
<a href="?modal=following" class={styles.subscribers}>
<For each={props.following.slice(0, 3)}>
{(f) => {
if ('name' in f) {
@ -259,34 +245,33 @@ export const AuthorCard = (props: Props) => {
}}
</For>
<div class={styles.subscribersCounter}>
{props.following.length}&nbsp;
{getNumeralsDeclension(props.following.length, [
t('subscription'),
t('subscription_rp'),
t('subscriptions')
])}
</div>
{t('SubscriberWithCount', { count: props?.following.length ?? 0 })}
</div>
</a>
</Show>
</div>
</Show>
</div>
<ShowOnlyOnClient>
<Show when={isSessionLoaded()}>
<Show when={canFollow()}>
<div class={styles.authorSubscribe}>
<Show when={!props.noSocialButtons && !props.hideWriteButton && props.author.links}>
<Show when={isSessionLoaded() && props.author.links}>
<div class={styles.authorSubscribeSocial}>
<For each={props.author.links}>
{(link) => (
<a href={link}>
<span class={styles.authorSubscribeSocialLabel}>{link}</span>
<a
class={styles.socialLink}
href={link.startsWith('http') ? link : `https://${link}`}
target="_blank"
rel="nofollow noopener"
>
<span class={styles.authorSubscribeSocialLabel}>
{link.startsWith('http') ? link : `https://${link}`}
</span>
</a>
)}
</For>
</div>
</Show>
<Show when={canFollow()}>
<div class={styles.authorSubscribe}>
<Show
when={subscribed()}
fallback={
@ -353,9 +338,8 @@ export const AuthorCard = (props: Props) => {
<Show when={!props.hideWriteButton}>
<button
class={styles.button}
class={clsx(styles.button, styles.buttonSubscribe)}
classList={{
[styles.buttonWriteAuthorPage]: !props.isAuthorsList,
'button--subscribe': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList,
[styles.buttonWrite]: props.liteButtons && props.isAuthorsList
@ -372,9 +356,6 @@ export const AuthorCard = (props: Props) => {
</Show>
</Show>
</ShowOnlyOnClient>
</div>
</div>
<Show when={props.followers}>
<Modal variant="medium" name="followers" onClose={handleCloseFollowModals} maxHeight>
<>
@ -392,6 +373,19 @@ export const AuthorCard = (props: Props) => {
</Modal>
</Show>
<Show when={props.isCurrentUser}>
<div class={styles.subscribersContainer}>
<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')} />}
/>
</div>
</Show>
<Show when={props.following}>
<Modal variant="medium" name="following" onClose={handleCloseFollowModals} maxHeight>
<>
@ -439,6 +433,7 @@ export const AuthorCard = (props: Props) => {
</>
</Modal>
</Show>
</>
</div>
</div>
)
}

View File

@ -68,7 +68,7 @@
&.big {
aspect-ratio: 1/1;
margin: 0 auto;
margin: 0 auto 1rem;
max-width: 168px;
height: auto;
width: 100%;

View File

@ -1,7 +1,8 @@
import '../../styles/help.scss'
import { createSignal, onMount } from 'solid-js'
import { showModal, warn } from '../../stores/ui'
import { showModal } from '../../stores/ui'
import { useLocalize } from '../../context/localize'
import { useSnackbar } from '../../context/snackbar'
export const Donate = () => {
const { t } = useLocalize()
@ -20,6 +21,9 @@ export const Donate = () => {
const [showingPayment, setShowingPayment] = createSignal<boolean>()
const [period, setPeriod] = createSignal(monthly)
const [amount, setAmount] = createSignal(0)
const {
actions: { showSnackbar }
} = useSnackbar()
const initiated = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -104,10 +108,10 @@ export const Donate = () => {
// fail
// действие при неуспешной оплате
console.debug('[donate] options', options)
warn({
kind: 'error',
body: reason,
seen: false
showSnackbar({
type: 'error',
body: reason
})
}
)

View File

@ -250,7 +250,7 @@
}
.mainNavigationItemActive {
background: var(--link-hover-background);
background: var(--link-hover-background) !important;
color: var(--link-hover-color) !important;
}

View File

@ -3,10 +3,9 @@ import { clsx } from 'clsx'
import { router, useRouter } from '../../stores/router'
import { Icon } from '../_shared/Icon'
import { createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js'
import Notifications from './Notifications'
import { ProfilePopup } from './ProfilePopup'
import { Userpic } from '../Author/Userpic'
import { showModal, useWarningsStore } from '../../stores/ui'
import { showModal } from '../../stores/ui'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { useSession } from '../../context/session'
import { useLocalize } from '../../context/localize'
@ -14,6 +13,7 @@ import { getPagePath } from '@nanostores/router'
import { Button } from '../_shared/Button'
import { useEditorContext } from '../../context/editor'
import { Popover } from '../_shared/Popover'
import { useNotifications } from '../../context/notifications'
type Props = {
setIsProfilePopupVisible: (value: boolean) => void
@ -29,18 +29,17 @@ const MD_WIDTH_BREAKPOINT = 992
export const HeaderAuth = (props: Props) => {
const { t } = useLocalize()
const { page } = useRouter()
const [visibleWarnings, setVisibleWarnings] = createSignal(false)
const { warnings } = useWarningsStore()
const { session, isSessionLoaded, isAuthenticated } = useSession()
const {
unreadNotificationsCount,
actions: { showNotificationsPanel }
} = useNotifications()
const {
form,
actions: { toggleEditorPanel, saveShout, publishShout }
} = useEditorContext()
const toggleWarnings = () => setVisibleWarnings(!visibleWarnings())
const handleBellIconClick = (event: Event) => {
event.preventDefault()
@ -48,15 +47,16 @@ export const HeaderAuth = (props: Props) => {
showModal('auth')
return
}
toggleWarnings()
showNotificationsPanel()
}
const isEditorPage = createMemo(() => page().route === 'edit' || page().route === 'editSettings')
const showNotifications = createMemo(() => isAuthenticated() && !isEditorPage())
const showSaveButton = createMemo(() => isAuthenticated() && isEditorPage())
const showCreatePostButton = createMemo(() => isAuthenticated() && !isEditorPage())
const showAuthenticatedControls = createMemo(() => isAuthenticated())
const isNotificationsVisible = createMemo(() => isAuthenticated() && !isEditorPage())
const isSaveButtonVisible = createMemo(() => isAuthenticated() && isEditorPage())
const isCreatePostButtonVisible = createMemo(() => isAuthenticated() && !isEditorPage())
const isAuthenticatedControlsVisible = createMemo(() => isAuthenticated())
const handleBurgerButtonClick = () => {
toggleEditorPanel()
@ -71,8 +71,9 @@ export const HeaderAuth = (props: Props) => {
}
const [width, setWidth] = createSignal(0)
const handleResize = () => setWidth(window.innerWidth)
onMount(() => {
const handleResize = () => setWidth(window.innerWidth)
handleResize()
window.addEventListener('resize', handleResize)
onCleanup(() => window.removeEventListener('resize', handleResize))
@ -109,7 +110,7 @@ export const HeaderAuth = (props: Props) => {
<Show when={isSessionLoaded()} keyed={true}>
<div class={clsx('col-sm-6 col-lg-7', styles.usernav)}>
<div class={styles.userControl}>
<Show when={showCreatePostButton()}>
<Show when={isCreatePostButtonVisible()}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
<a href={getPagePath(router, 'create')}>
<span class={styles.textLabel}>{t('Create post')}</span>
@ -126,26 +127,19 @@ export const HeaderAuth = (props: Props) => {
</a>
</div>
<Show when={showNotifications()}>
<div class={styles.userControlItem}>
<a href="#" onClick={handleBellIconClick}>
<div>
<Icon
name="bell-white"
counter={isAuthenticated() ? warnings().length : 1}
class={styles.icon}
/>
<Show when={isNotificationsVisible()}>
<div class={styles.userControlItem} onClick={handleBellIconClick}>
{/*TODO: check markup (cursor: pointer, hover)*/}
<Icon name="bell-white" counter={unreadNotificationsCount()} class={styles.icon} />
<Icon
name="bell-white-hover"
counter={isAuthenticated() ? warnings().length : 1}
counter={unreadNotificationsCount()}
class={clsx(styles.icon, styles.iconHover)}
/>
</div>
</a>
</div>
</Show>
<Show when={showSaveButton()}>
<Show when={isSaveButtonVisible()}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
{renderIconedButton({
value: t('Save'),
@ -175,15 +169,8 @@ export const HeaderAuth = (props: Props) => {
</Popover>
</div>
</Show>
<Show when={visibleWarnings()}>
<div class={clsx(styles.userControlItem, 'notifications')}>
<Notifications />
</div>
</Show>
<Show
when={showAuthenticatedControls()}
when={isAuthenticatedControlsVisible()}
fallback={
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}>
<a href="?modal=auth&mode=login">

View File

@ -7,6 +7,7 @@ import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
import styles from './Modal.module.scss'
import { redirectPage } from '@nanostores/router'
import { router } from '../../../stores/router'
import { Icon } from '../../_shared/Icon'
interface Props {
name: string
@ -55,18 +56,7 @@ export const Modal = (props: Props) => {
>
<div class={styles.modalInner}>{props.children}</div>
<div class={styles.close} onClick={handleHide}>
<svg
class={styles.icon}
width="16"
height="18"
viewBox="0 0 16 18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.99987 7.52552L14.1871 0.92334L15.9548 2.80968L9.76764 9.41185L15.9548 16.014L14.1871 17.9004L7.99987 11.2982L1.81269 17.9004L0.0449219 16.014L6.23211 9.41185L0.0449225 2.80968L1.81269 0.92334L7.99987 7.52552Z"
fill="currentColor"
/>
</svg>
<Icon name="close" class={styles.icon} />
</div>
</div>
</div>

View File

@ -1,19 +0,0 @@
import { Portal } from 'solid-js/web'
import { useWarningsStore } from '../../stores/ui'
import { createMemo, For, Show } from 'solid-js'
export default () => {
const { warnings } = useWarningsStore()
const notSeen = createMemo(() => warnings().filter((warning) => !warning.seen))
return (
<Show when={notSeen().length > 0}>
<Portal>
<ul class="warns">
<For each={warnings()}>{(warning) => <li>{warning.body}</li>}</For>
</ul>
</Portal>
</Show>
)
}

View File

@ -26,7 +26,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
<a href={getPagePath(router, 'drafts')}>{t('Drafts')}</a>
</li>
<li>
<a href={`${getPagePath(router, 'authorFollowing', { slug: user().slug })}`}>
<a href={`${getPagePath(router, 'author', { slug: user().slug })}?modal=following`}>
{t('Subscriptions')}
</a>
</li>

View File

@ -0,0 +1,32 @@
.NotificationView {
display: flex;
align-items: center;
height: 72px;
margin-left: -16px;
border-radius: 16px;
padding: 16px;
background-color: var(--yellow-50);
// TODO: check markup
font-size: 15px;
// font-weight: 700;
line-height: 20px;
cursor: pointer;
transition: background-color 100ms;
&.seen {
background-color: transparent;
}
&:hover {
background-color: var(--gray-100);
}
}
.userpic {
margin-right: 15px;
}
.timeContainer {
margin-left: auto;
padding-left: 16px;
}

View File

@ -0,0 +1,128 @@
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 { useNotifications } from '../../../context/notifications'
import { Userpic } from '../../Author/Userpic'
import { useLocalize } from '../../../context/localize'
import notifications from '../../../graphql/query/notifications'
type Props = {
notification: Notification
onClick: () => void
class?: string
}
type NotificationData = {
shout: {
slug: string
title: string
}
users: {
id: number
name: string
slug: string
userpic: string
}[]
}
export const NotificationView = (props: Props) => {
const {
actions: { markNotificationAsRead }
} = useNotifications()
const { t } = useLocalize()
const [data, setData] = createSignal<NotificationData>(null)
onMount(() => {
setTimeout(() => setData(JSON.parse(props.notification.data)))
})
const lastUser = createMemo(() => {
if (!data()) {
return null
}
return data().users[data().users.length - 1]
})
const content = createMemo(() => {
if (!data()) {
return null
}
let shoutTitle = ''
let i = 0
const shoutTitleWords = data().shout.title.split(' ')
while (shoutTitle.length <= 30 && i < shoutTitleWords.length) {
shoutTitle += shoutTitleWords[i] + ' '
i++
}
if (shoutTitle.length < data().shout.title.length) {
shoutTitle += '...'
}
switch (props.notification.type) {
case NotificationType.NewComment: {
return t('NewCommentNotificationText', {
commentsCount: props.notification.occurrences,
shoutTitle,
lastCommenterName: lastUser().name,
restUsersCount: data().users.length - 1
})
}
case NotificationType.NewReply: {
return t('NewReplyNotificationText', {
commentsCount: props.notification.occurrences,
shoutTitle,
lastCommenterName: lastUser().name,
restUsersCount: data().users.length - 1
})
}
}
})
const handleClick = () => {
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
// }
// }
}
return (
<Show when={data()}>
<div
class={clsx(styles.NotificationView, props.class, {
[styles.seen]: props.notification.seen
})}
onClick={handleClick}
>
<Userpic name={lastUser().name} userpic={lastUser().userpic} class={styles.userpic} />
<div>{content()}</div>
<div class={styles.timeContainer}>
{/*{formatDate(new Date(props.notification.createdAt), { month: 'numeric' })}*/}
</div>
</div>
</Show>
)
}

View File

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

View File

@ -0,0 +1,66 @@
$transition-duration: 200ms;
.container {
display: flex;
align-items: stretch;
justify-content: flex-end;
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 0;
z-index: 10000;
background-color: rgb(0 0 0 / 0%);
overflow: hidden;
transition:
background-color $transition-duration,
width 0ms linear $transition-duration;
.panel {
position: relative;
background-color: #fff;
width: 700px;
padding: 48px 96px 96px 48px;
transform: translateX(100%);
transition: transform $transition-duration;
overflow-y: auto;
}
&.isOpened {
width: 100%;
background-color: rgb(0 0 0 / 60%);
transition:
background-color $transition-duration,
width 0ms;
.panel {
transform: translateX(0);
}
}
}
.title {
// TODO: check markup
color: var(--black-500, #141414);
font-size: 32px;
font-style: normal;
font-weight: 700;
line-height: 36px;
margin-bottom: 32px;
}
.closeButton {
position: absolute;
top: 0;
right: 0;
padding: 20px;
cursor: pointer;
}
.notificationView + .notificationView {
margin-top: 8px;
}
.emptyMessageContainer {
text-align: center;
}

View File

@ -0,0 +1,70 @@
import { clsx } from 'clsx'
import styles from './NotificationsPanel.module.scss'
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 { useNotifications } from '../../context/notifications'
import { NotificationView } from './NotificationView'
type Props = {
isOpen: boolean
onClose: () => void
}
export const NotificationsPanel = (props: Props) => {
const { t } = useLocalize()
const { sortedNotifications } = useNotifications()
const handleHide = () => {
props.onClose()
}
const panelRef: { current: HTMLDivElement } = {
current: null
}
useOutsideClickHandler({
containerRef: panelRef,
predicate: () => props.isOpen,
handler: () => handleHide()
})
createEffect(() => {
document.body.classList.toggle('fixed', props.isOpen)
})
useEscKeyDownHandler(handleHide)
const handleNotificationViewClick = () => {
handleHide()
}
return (
<div
class={clsx(styles.container, {
[styles.isOpened]: props.isOpen
})}
>
<div ref={(el) => (panelRef.current = el)} class={styles.panel}>
<div class={styles.closeButton} onClick={handleHide}>
{/*TODO: check markup (hover)*/}
<Icon name="close" />
</div>
<div class={styles.title}>{t('Notifications')}</div>
<For
each={sortedNotifications()}
fallback={<div class={styles.emptyMessageContainer}>{t('No notifications, yet')}</div>}
>
{(notification) => (
<NotificationView
notification={notification}
class={styles.notificationView}
onClick={handleNotificationViewClick}
/>
)}
</For>
</div>
</div>
)
}

View File

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

View File

@ -60,7 +60,7 @@
margin-bottom: 1.2rem;
margin-top: 0.5rem !important;
a {
a:link {
border: none;
}
}

View File

@ -14,10 +14,14 @@
}
.authorHeader {
border-bottom: 2px solid #000;
border-bottom: 2px solid var(--default-color);
margin-bottom: 2.4rem;
margin-top: -3.2rem;
margin-top: 1.8rem;
padding-bottom: 4rem;
@include media-breakpoint-up(lg) {
margin-top: -3.2rem;
}
}
.ratingContainer {

View File

@ -18,6 +18,7 @@ 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'
type Props = {
shouts: Shout[]
@ -33,7 +34,8 @@ export const AuthorView = (props: Props) => {
const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
const { authorEntities } = useAuthorsStore({ authors: [props.author] })
const { page } = useRouter()
const { page: getPage } = useRouter()
const { user } = useSession()
const author = createMemo(() => authorEntities()[props.authorSlug])
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [isBioExpanded, setIsBioExpanded] = createSignal(false)
@ -102,14 +104,14 @@ export const AuthorView = (props: Props) => {
// return t('Top recent')
// })
const shouts = createMemo<Shout[][]>(() =>
const pages = createMemo<Shout[][]>(() =>
splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE)
)
const [commented, setCommented] = createSignal([])
createEffect(async () => {
if (page().route === 'authorComments') {
if (getPage().route === 'authorComments') {
try {
const data = await apiClient.getReactionsBy({
by: { comment: true, createdBy: props.authorSlug }
@ -131,23 +133,24 @@ export const AuthorView = (props: Props) => {
isAuthorPage={true}
followers={followers()}
following={following()}
isCurrentUser={author().slug === user()?.slug}
/>
</div>
</Show>
<div class={clsx(styles.groupControls, 'row')}>
<div class="col-md-16">
<ul class="view-switcher">
<li classList={{ 'view-switcher__item--selected': page().route === 'author' }}>
<li classList={{ 'view-switcher__item--selected': getPage().route === 'author' }}>
<a href={getPagePath(router, 'author', { slug: props.authorSlug })}>{t('Publications')}</a>
<span class="view-switcher__counter">{author().stat.shouts}</span>
<span class="view-switcher__counter">{author().stat?.shouts}</span>
</li>
<li classList={{ 'view-switcher__item--selected': page().route === 'authorComments' }}>
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorComments' }}>
<a href={getPagePath(router, 'authorComments', { slug: props.authorSlug })}>
{t('Comments')}
</a>
<span class="view-switcher__counter">{author().stat.commented}</span>
<span class="view-switcher__counter">{author().stat?.commented}</span>
</li>
<li classList={{ 'view-switcher__item--selected': page().route === 'authorAbout' }}>
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorAbout' }}>
<a
onClick={() => checkBioHeight()}
href={getPagePath(router, 'authorAbout', { slug: props.authorSlug })}
@ -167,7 +170,7 @@ export const AuthorView = (props: Props) => {
</div>
<Switch>
<Match when={page().route === 'authorAbout'}>
<Match when={getPage().route === 'authorAbout'}>
<div class="wide-container">
<div class="row">
<div class="col-md-20 col-lg-18">
@ -191,7 +194,7 @@ export const AuthorView = (props: Props) => {
</div>
</div>
</Match>
<Match when={page().route === 'authorComments'}>
<Match when={getPage().route === 'authorComments'}>
<div class="wide-container">
<div class="row">
<div class="col-md-20 col-lg-18">
@ -204,8 +207,7 @@ export const AuthorView = (props: Props) => {
</div>
</div>
</Match>
<Match when={page().route === 'author'}>
<Match when={getPage().route === 'author'}>
<Show when={sortedArticles().length === 1}>
<Row1 article={sortedArticles()[0]} noauthor={true} nodate={true} />
</Show>
@ -226,15 +228,15 @@ export const AuthorView = (props: Props) => {
<Row1 article={sortedArticles()[6]} noauthor={true} nodate={true} />
<Row2 articles={sortedArticles().slice(7, 9)} isEqual={true} noauthor={true} />
<For each={shouts()}>
{(shout) => (
<For each={pages()}>
{(page) => (
<>
<Row1 article={shout[0]} noauthor={true} nodate={true} />
<Row2 articles={shout.slice(1, 3)} isEqual={true} noauthor={true} />
<Row1 article={shout[3]} noauthor={true} nodate={true} />
<Row2 articles={shout.slice(4, 6)} isEqual={true} noauthor={true} />
<Row1 article={shout[6]} noauthor={true} nodate={true} />
<Row2 articles={shout.slice(7, 9)} isEqual={true} noauthor={true} />
<Row1 article={page[0]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(1, 3)} isEqual={true} noauthor={true} />
<Row1 article={page[3]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(4, 6)} isEqual={true} noauthor={true} />
<Row1 article={page[6]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(7, 9)} isEqual={true} noauthor={true} />
</>
)}
</For>

View File

@ -1,4 +1,4 @@
.withPadding {
main.withPadding {
padding-top: 58px;
}

View File

@ -31,7 +31,7 @@ export const PageLayout = (props: Props) => {
props.scrollToComments(scrollToComments())
}
})
// const { randomTopics } = useTopicsStore()
return (
<>
<Meta name="viewport" content="width=device-width, initial-scale=1" />

View File

@ -73,7 +73,8 @@
padding-top: 1em;
}
a {
a:link,
:global(.link) {
border: none;
white-space: nowrap;
@ -100,7 +101,9 @@
.shareControl {
text-align: left;
transition: color 0.3s, background-color 0.3s;
transition:
color 0.3s,
background-color 0.3s;
white-space: nowrap;
&:hover {

View File

@ -0,0 +1,106 @@
import type { Accessor, JSX } from 'solid-js'
import { createContext, createEffect, createMemo, createSignal, useContext } from 'solid-js'
import { useSession } from './session'
import SSEService, { EventData } from '../utils/sseService'
import { apiBaseUrl } from '../utils/config'
import { Portal } from 'solid-js/web'
import { ShowIfAuthenticated } from '../components/_shared/ShowIfAuthenticated'
import { NotificationsPanel } from '../components/NotificationsPanel'
import { apiClient } from '../utils/apiClient'
import { createStore } from 'solid-js/store'
import { Notification } from '../graphql/types.gen'
type NotificationsContextType = {
notificationEntities: Record<number, Notification>
unreadNotificationsCount: Accessor<number>
sortedNotifications: Accessor<Notification[]>
actions: {
showNotificationsPanel: () => void
markNotificationAsRead: (notification: Notification) => Promise<void>
}
}
const NotificationsContext = createContext<NotificationsContextType>()
export function useNotifications() {
return useContext(NotificationsContext)
}
const sseService = new SSEService()
export const NotificationsProvider = (props: { children: JSX.Element }) => {
const [isNotificationsPanelOpen, setIsNotificationsPanelOpen] = createSignal(false)
const [unreadNotificationsCount, setUnreadNotificationsCount] = createSignal(0)
const { isAuthenticated, user } = useSession()
const [notificationEntities, setNotificationEntities] = createStore<Record<number, Notification>>({})
const loadNotifications = async () => {
const { notifications, totalUnreadCount } = await apiClient.getNotifications({
limit: 100
})
const newNotificationEntities = notifications.reduce((acc, notification) => {
acc[notification.id] = notification
return acc
}, {})
setUnreadNotificationsCount(totalUnreadCount)
setNotificationEntities(newNotificationEntities)
return notifications
}
const sortedNotifications = createMemo(() => {
return Object.values(notificationEntities).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
})
createEffect(() => {
if (isAuthenticated()) {
loadNotifications()
sseService.connect(`${apiBaseUrl}/subscribe/${user().id}`)
sseService.subscribeToEvent('message', (data: EventData) => {
if (data.type === 'newNotifications') {
loadNotifications()
} else {
console.error(`[NotificationsProvider] unknown message type: ${JSON.stringify(data)}`)
}
})
} else {
sseService.disconnect()
}
})
const markNotificationAsRead = async (notification: Notification) => {
await apiClient.markNotificationAsRead(notification.id)
loadNotifications()
}
const showNotificationsPanel = () => {
setIsNotificationsPanelOpen(true)
}
const actions = { showNotificationsPanel, markNotificationAsRead }
const value: NotificationsContextType = {
notificationEntities,
sortedNotifications,
unreadNotificationsCount,
actions
}
const handleNotificationPanelClose = () => {
setIsNotificationsPanelOpen(false)
}
return (
<NotificationsContext.Provider value={value}>
{props.children}
<ShowIfAuthenticated>
<Portal>
<NotificationsPanel isOpen={isNotificationsPanelOpen()} onClose={handleNotificationPanelClose} />
</Portal>
</ShowIfAuthenticated>
</NotificationsContext.Provider>
)
}

View File

@ -1,7 +1,5 @@
import { gql } from '@urql/core'
// WARNING: need Auth header
export default gql`
query SignOutQuery {
signOut {

View File

@ -0,0 +1,9 @@
import { gql } from '@urql/core'
export default gql`
mutation MarkNotificationAsReadMutation($notificationId: Int!) {
markNotificationAsRead(notification_id: $notificationId) {
error
}
}
`

View File

@ -1,5 +1,4 @@
import { gql } from '@urql/core'
export default gql`
mutation UnfollowMutation($what: FollowingEntity!, $slug: String!) {
unfollow(what: $what, slug: $slug) {

View File

@ -1,5 +1,4 @@
import { gql } from '@urql/core'
// WARNING: need Auth header
export default gql`
mutation ProfileUpdateMutation($profile: ProfileInput!) {

View File

@ -6,6 +6,7 @@ export default gql`
error
token
user {
id
name
slug
userpic

View File

@ -1,7 +1,5 @@
import { gql } from '@urql/core'
// WARNING: need Auth header
export default gql`
query ShoutsReactedByUserQuery($slug: String!, $limit: Int!, $offset: Int!) {
userReactedShouts(slug: String!, page: Int!, size: Int!) {

View File

@ -0,0 +1,20 @@
import { gql } from '@urql/core'
export default gql`
query LoadNotificationsQuery {
loadNotifications(params: { limit: 10, offset: 0 }) {
notifications {
id
shout
reaction
type
createdAt
seen
data
occurrences
}
totalCount
totalUnreadCount
}
}
`

View File

@ -173,14 +173,15 @@ export type Mutation = {
destroyTopic: Result
follow: Result
getSession: AuthResult
markAllNotificationsAsRead: Result
markAsRead: Result
markNotificationAsRead: Result
rateUser: Result
registerUser: AuthResult
sendLink: Result
unfollow: Result
updateChat: Result
updateMessage: Result
updateOnlineStatus: Result
updateProfile: Result
updateReaction: Result
updateShout: Result
@ -245,6 +246,10 @@ export type MutationMarkAsReadArgs = {
ids: Array<InputMaybe<Scalars['Int']>>
}
export type MutationMarkNotificationAsReadArgs = {
notification_id: Scalars['Int']
}
export type MutationRateUserArgs = {
slug: Scalars['String']
value: Scalars['Int']
@ -296,6 +301,33 @@ export type MutationUpdateTopicArgs = {
input: TopicInput
}
export type Notification = {
createdAt: Scalars['DateTime']
data?: Maybe<Scalars['String']>
id: Scalars['Int']
occurrences: Scalars['Int']
reaction?: Maybe<Scalars['Int']>
seen: Scalars['Boolean']
shout?: Maybe<Scalars['Int']>
type?: Maybe<NotificationType>
}
export enum NotificationType {
NewComment = 'NEW_COMMENT',
NewReply = 'NEW_REPLY'
}
export type NotificationsQueryParams = {
limit?: InputMaybe<Scalars['Int']>
offset?: InputMaybe<Scalars['Int']>
}
export type NotificationsQueryResult = {
notifications: Array<Maybe<Notification>>
totalCount: Scalars['Int']
totalUnreadCount: Scalars['Int']
}
export type Operation = {
id: Scalars['Int']
name: Scalars['String']
@ -324,6 +356,7 @@ export type Query = {
loadChats: Result
loadDrafts: Array<Maybe<Shout>>
loadMessagesBy: Result
loadNotifications: NotificationsQueryResult
loadReactionsBy: Array<Maybe<Reaction>>
loadRecipients: Result
loadShout?: Maybe<Shout>
@ -372,6 +405,10 @@ export type QueryLoadMessagesByArgs = {
offset?: InputMaybe<Scalars['Int']>
}
export type QueryLoadNotificationsArgs = {
params: NotificationsQueryParams
}
export type QueryLoadReactionsByArgs = {
by: ReactionBy
limit?: InputMaybe<Scalars['Int']>
@ -598,12 +635,6 @@ export type Stat = {
viewed?: Maybe<Scalars['Int']>
}
export type Subscription = {
newMessage?: Maybe<Message>
newReaction?: Maybe<Reaction>
newShout?: Maybe<Shout>
}
export type Token = {
createdAt: Scalars['DateTime']
expiresAt?: Maybe<Scalars['DateTime']>

View File

@ -1,4 +0,0 @@
import { ROUTES } from '../stores/router'
import { getServerRoute } from '../utils/getServerRoute'
export default getServerRoute(ROUTES.authorFollowers)

View File

@ -1,4 +0,0 @@
import { ROUTES } from '../stores/router'
import { getServerRoute } from '../utils/getServerRoute'
export default getServerRoute(ROUTES.authorFollowing)

View File

@ -18,8 +18,6 @@ export const ROUTES = {
author: '/author/:slug',
authorComments: '/author/:slug/comments',
authorAbout: '/author/:slug/about',
authorFollowers: '/author/:slug/followers',
authorFollowing: '/author/:slug/following',
feed: '/feed',
feedMy: '/feed/my',
feedNotifications: '/feed/notifications',

View File

@ -23,14 +23,6 @@ export type ModalType =
| 'followers'
| 'following'
type WarnKind = 'error' | 'warn' | 'info'
export interface Warning {
body: string
kind: WarnKind
seen?: boolean
}
export const MODALS: Record<ModalType, ModalType> = {
auth: 'auth',
subscribe: 'subscribe',
@ -50,8 +42,6 @@ export const MODALS: Record<ModalType, ModalType> = {
const [modal, setModal] = createSignal<ModalType>(null)
const [warnings, setWarnings] = createSignal<Warning[]>([])
const { searchParams, changeSearchParam } = useRouter<
AuthModalSearchParams & ConfirmEmailSearchParams & RootSearchParams
>()
@ -85,15 +75,6 @@ export const hideModal = () => {
setModal(null)
}
export const clearWarns = () => setWarnings([])
export const warn = (warning: Warning) => setWarnings([...warnings(), warning])
export const useWarningsStore = () => {
return {
warnings
}
}
export const useModalStore = () => {
return {
modal

View File

@ -36,6 +36,8 @@
--black-400: #696969;
--white-500: #fff;
--blue-500: #2638d9;
--yellow-50: #fffbeb;
--gray-100: #f3f4f6;
}
[data-editor-dark-mode='true'] {
@ -645,7 +647,7 @@ figure {
cursor: default;
&:hover {
color: #fff;
background: #fff;
}
}
}

View File

@ -15,7 +15,9 @@ import type {
ReactionInput,
Chat,
ReactionBy,
Shout
Shout,
NotificationsQueryParams,
NotificationsQueryResult
} from '../graphql/types.gen'
import { publicGraphQLClient } from '../graphql/publicGraphQLClient'
import { getToken, privateGraphQLClient, privateInboxGraphQLClient } from '../graphql/privateGraphQLClient'
@ -54,6 +56,8 @@ import createMessage from '../graphql/mutation/create-chat-message'
import updateProfile from '../graphql/mutation/update-profile'
import updateArticle from '../graphql/mutation/article-update'
import deleteShout from '../graphql/mutation/article-delete'
// import notifications from '../graphql/query/notifications'
// import markNotificationAsRead from '../graphql/mutation/mark-notification-as-read'
type ApiErrorCode =
| 'unknown'
@ -349,6 +353,21 @@ export const apiClient = {
// console.debug(resp)
return resp.data.loadReactionsBy
}
// TODO: store notifications in browser storage
/*
getNotifications: async (params: NotificationsQueryParams): Promise<NotificationsQueryResult> => {
const resp = await privateGraphQLClient.query(notifications, params).toPromise()
console.debug(resp.data)
return resp.data.loadNotifications
},
markNotificationAsRead: async (notificationId: number): Promise<void> => {
await privateGraphQLClient
.mutation(markNotificationAsRead, {
notificationId
})
.toPromise()
},
*/
}
export const inboxClient = {

View File

@ -5,7 +5,6 @@ const pageLoadManager: {
export const getPageLoadManagerPromise = () => {
return pageLoadManager.promise
}
export const setPageLoadManagerPromise = (promise: Promise<any>) => {
pageLoadManager.promise = promise
}

33
src/utils/sseService.ts Normal file
View File

@ -0,0 +1,33 @@
export type EventData = {
type: string
}
class SSEService {
private eventSource: EventSource | null
constructor() {
this.eventSource = null
}
public connect(url: string): void {
this.eventSource = new EventSource(url)
}
public disconnect(): void {
if (this.eventSource) {
this.eventSource.close()
this.eventSource = null
}
}
public subscribeToEvent(eventName: string, callback: (eventData: EventData) => void): void {
if (this.eventSource) {
this.eventSource.addEventListener(eventName, (event: MessageEvent) => {
const data = JSON.parse(event.data)
callback(data)
})
}
}
}
export default SSEService