Feature/notifications (#244)
* WIP * WIP * WIP * packaga-lock.json * WIP * WIP * WIP * WIP * v0.1 * debug code removed --------- Co-authored-by: Igor Lobanov <igor.lobanov@onetwotrip.com>
This commit is contained in:
parent
7c74980464
commit
2674717f04
|
@ -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",
|
||||
|
@ -414,6 +416,5 @@
|
|||
"view": "view",
|
||||
"zine": "zine",
|
||||
"SubscriptionWithPlurals": "{count, plural, =0 {no subscriptions} one {{count} subscription} other {{count} subscriptions}",
|
||||
"PublicationsWithCount": "{count, plural, =0 {no publications} one {{count} publication} other {{count} publications}}",
|
||||
"Edit profile": "Edit profile"
|
||||
}
|
||||
|
|
|
@ -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": "Настройки публикации",
|
||||
|
@ -438,6 +440,7 @@
|
|||
"zine": "журнал",
|
||||
"SubscriberWithCount": "{count, plural, =0 {нет подписчиков} one {{count} подписчик} few {{count} подписчика} other {{count} подписчиков}}",
|
||||
"SubscriptionWithCount": "{count, plural, =0 {нет подписок} one {{count} подписка} few {{count} подписки} other {{count} подписок}}",
|
||||
"PublicationsWithCount": "{count, plural, =0 {нет публикаций} one {{count} публикация} few {{count} публикации} other {{count} публикаций}}",
|
||||
"Edit profile": "Редактировать профиль"
|
||||
"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} пользователей}}"
|
||||
}
|
||||
|
|
|
@ -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'))
|
||||
|
@ -113,9 +114,11 @@ export const App = (props: PageProps) => {
|
|||
<SnackbarProvider>
|
||||
<ConfirmProvider>
|
||||
<SessionProvider>
|
||||
<NotificationsProvider>
|
||||
<EditorProvider>
|
||||
<Dynamic component={pageComponent()} {...props} />
|
||||
</EditorProvider>
|
||||
</NotificationsProvider>
|
||||
</SessionProvider>
|
||||
</ConfirmProvider>
|
||||
</SnackbarProvider>
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { NotificationView } from './NotificationView'
|
|
@ -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;
|
||||
}
|
70
src/components/NotificationsPanel/NotificationsPanel.tsx
Normal file
70
src/components/NotificationsPanel/NotificationsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
src/components/NotificationsPanel/index.ts
Normal file
1
src/components/NotificationsPanel/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { NotificationsPanel } from './NotificationsPanel'
|
|
@ -34,7 +34,7 @@ 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)
|
||||
|
@ -104,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 }
|
||||
|
@ -140,17 +140,17 @@ export const AuthorView = (props: Props) => {
|
|||
<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>
|
||||
</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>
|
||||
</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 })}
|
||||
|
@ -170,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">
|
||||
|
@ -194,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">
|
||||
|
@ -207,7 +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>
|
||||
|
@ -228,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>
|
||||
|
|
|
@ -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" />
|
||||
|
|
106
src/context/notifications.tsx
Normal file
106
src/context/notifications.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
// WARNING: need Auth header
|
||||
|
||||
export default gql`
|
||||
query SignOutQuery {
|
||||
signOut {
|
||||
|
|
9
src/graphql/mutation/mark-notification-as-read.ts
Normal file
9
src/graphql/mutation/mark-notification-as-read.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
mutation MarkNotificationAsReadMutation($notificationId: Int!) {
|
||||
markNotificationAsRead(notification_id: $notificationId) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
|
@ -1,5 +1,4 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
mutation UnfollowMutation($what: FollowingEntity!, $slug: String!) {
|
||||
unfollow(what: $what, slug: $slug) {
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { gql } from '@urql/core'
|
||||
// WARNING: need Auth header
|
||||
|
||||
export default gql`
|
||||
mutation ProfileUpdateMutation($profile: ProfileInput!) {
|
||||
|
|
|
@ -6,6 +6,7 @@ export default gql`
|
|||
error
|
||||
token
|
||||
user {
|
||||
id
|
||||
name
|
||||
slug
|
||||
userpic
|
||||
|
|
|
@ -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!) {
|
||||
|
|
20
src/graphql/query/notifications.ts
Normal file
20
src/graphql/query/notifications.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
|
@ -1,15 +0,0 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
subscription {
|
||||
newMessage {
|
||||
id
|
||||
chatId
|
||||
author
|
||||
body
|
||||
replyTo
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
|
@ -1,26 +0,0 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
subscription {
|
||||
newReactions {
|
||||
id
|
||||
body
|
||||
kind
|
||||
range
|
||||
createdAt
|
||||
replyTo
|
||||
stat {
|
||||
rating
|
||||
}
|
||||
shout {
|
||||
id
|
||||
slug
|
||||
}
|
||||
createdBy {
|
||||
name
|
||||
slug
|
||||
userpic
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
|
@ -1,25 +0,0 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
subscription {
|
||||
newShout {
|
||||
id
|
||||
slug
|
||||
title
|
||||
subtitle
|
||||
body
|
||||
topics {
|
||||
# id
|
||||
title
|
||||
slug
|
||||
}
|
||||
authors {
|
||||
id
|
||||
name
|
||||
slug
|
||||
userpic
|
||||
caption
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
|
@ -1,33 +0,0 @@
|
|||
---
|
||||
import './styles/app.scss'
|
||||
import { Seo } from "astro-seo-meta"
|
||||
|
||||
const ln = Astro.url.searchParams.get('lng') || 'ru'
|
||||
const { protocol, host } = Astro.url
|
||||
const title = ln === 'ru' ? 'Дискурс' : 'Discours'
|
||||
const imageUrl = Astro.props.imageUrl ?? `${protocol}${host}/public/bonfire.png`
|
||||
const description = ln === 'ru' ? 'Горизонтальная платформа коллаборативной журналистики' : 'Horizontal collaborative journalistic platform'
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<Seo
|
||||
title={title}
|
||||
icon="favicon.png"
|
||||
facebook={{
|
||||
image: imageUrl,
|
||||
type: "website",
|
||||
}}
|
||||
twitter={{
|
||||
image: imageUrl,
|
||||
card: description,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
---
|
||||
import { Root } from '../../components/Root'
|
||||
import Prerendered from '../../main.astro'
|
||||
import { apiClient } from '../../utils/apiClient'
|
||||
import { PRERENDERED_ARTICLES_COUNT } from '../../components/Views/Topic'
|
||||
|
||||
const slug = Astro.params.slug?.toString() || ''
|
||||
const shouts = await apiClient.getShouts({ filters: { topic: slug }, limit: PRERENDERED_ARTICLES_COUNT })
|
||||
const topic = await apiClient.getTopic({ slug })
|
||||
|
||||
import { initRouter } from '../../stores/router'
|
||||
|
||||
const { pathname, search } = Astro.url
|
||||
initRouter(pathname, search)
|
||||
|
||||
Astro.response.headers.set('Cache-Control', 's-maxage=1, stale-while-revalidate')
|
||||
---
|
||||
|
||||
<Prerendered>
|
||||
<Root topicShouts={shouts} topic={topic} client:load />
|
||||
</Prerendered>
|
|
@ -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
|
||||
|
|
|
@ -36,6 +36,8 @@
|
|||
--black-400: #696969;
|
||||
--white-500: #fff;
|
||||
--blue-500: #2638d9;
|
||||
--yellow-50: #fffbeb;
|
||||
--gray-100: #f3f4f6;
|
||||
}
|
||||
|
||||
[data-editor-dark-mode='true'] {
|
||||
|
|
|
@ -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 } 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,18 @@ export const apiClient = {
|
|||
// console.debug(resp)
|
||||
return resp.data.loadReactionsBy
|
||||
},
|
||||
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()
|
||||
},
|
||||
|
||||
// inbox
|
||||
getChats: async (options: QueryLoadChatsArgs): Promise<Chat[]> => {
|
||||
|
|
|
@ -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
33
src/utils/sseService.ts
Normal 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
|
Loading…
Reference in New Issue
Block a user