dates in notifications, lots of minor fixes (#271)

* dates in notifications, lots of minor fixes
Co-authored-by: Igor Lobanov <igor.lobanov@onetwotrip.com>
This commit is contained in:
Kosta 2023-10-18 13:56:41 +03:00 committed by GitHub
parent d1b17e47b3
commit 85e8533931
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 450 additions and 276 deletions

31
package-lock.json generated
View File

@ -95,6 +95,7 @@
"husky": "8.0.3",
"hygen": "6.2.11",
"i18next-http-backend": "2.2.0",
"javascript-time-ago": "2.5.9",
"jest": "29.7.0",
"js-cookie": "3.0.5",
"lint-staged": "14.0.1",
@ -11426,6 +11427,15 @@
"node": ">=8"
}
},
"node_modules/javascript-time-ago": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.5.9.tgz",
"integrity": "sha512-pQ8mNco/9g9TqWXWWjP0EWl6i/lAQScOyEeXy5AB+f7MfLSdgyV9BJhiOD1zrIac/lrxPYOWNbyl/IW8CW5n0A==",
"dev": true,
"dependencies": {
"relative-time-format": "^1.1.6"
}
},
"node_modules/jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
@ -16237,6 +16247,12 @@
"jsesc": "bin/jsesc"
}
},
"node_modules/relative-time-format": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/relative-time-format/-/relative-time-format-1.1.6.tgz",
"integrity": "sha512-aCv3juQw4hT1/P/OrVltKWLlp15eW1GRcwP1XdxHrPdZE9MtgqFpegjnTjLhi2m2WI9MT/hQQtE+tjEWG1hgkQ==",
"dev": true
},
"node_modules/relay-runtime": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-12.0.0.tgz",
@ -26822,6 +26838,15 @@
}
}
},
"javascript-time-ago": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.5.9.tgz",
"integrity": "sha512-pQ8mNco/9g9TqWXWWjP0EWl6i/lAQScOyEeXy5AB+f7MfLSdgyV9BJhiOD1zrIac/lrxPYOWNbyl/IW8CW5n0A==",
"dev": true,
"requires": {
"relative-time-format": "^1.1.6"
}
},
"jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
@ -30313,6 +30338,12 @@
}
}
},
"relative-time-format": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/relative-time-format/-/relative-time-format-1.1.6.tgz",
"integrity": "sha512-aCv3juQw4hT1/P/OrVltKWLlp15eW1GRcwP1XdxHrPdZE9MtgqFpegjnTjLhi2m2WI9MT/hQQtE+tjEWG1hgkQ==",
"dev": true
},
"relay-runtime": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-12.0.0.tgz",

View File

@ -115,6 +115,7 @@
"husky": "8.0.3",
"hygen": "6.2.11",
"i18next-http-backend": "2.2.0",
"javascript-time-ago": "2.5.9",
"jest": "29.7.0",
"js-cookie": "3.0.5",
"lint-staged": "14.0.1",

View File

@ -210,21 +210,17 @@
"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!",
"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",
"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",
"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}}",
"Notifications": "Notifications",
"Or paste a link to an image": "Or paste a link to an image",
"Ordered list": "Ordered list",
@ -369,6 +365,7 @@
"Write about the topic": "Write about the topic",
"Write an article": "Write an article",
"Write comment": "Write comment",
"Write good articles, comment\nand it won't be so empty here": "Write good articles, comment\nand it won't be so empty here",
"Write message": "Write a message",
"Write to us": "Write to us",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "You can download multiple tracks at once in .mp3, .wav or .flac formats",
@ -385,6 +382,7 @@
"article": "article",
"author": "author",
"authors": "authors",
"authorsWithCount": "{count} {count, plural, one {author} other {authors}}",
"back to menu": "back to menu",
"bold": "bold",
"bookmarks": "bookmarks",
@ -395,10 +393,12 @@
"delimiter": "delimiter",
"discussion": "discourse",
"drafts": "drafts",
"earlier": "earlier",
"email not confirmed": "email not confirmed",
"enter": "enter",
"feed": "feed",
"follower": "follower",
"followersWithCount": "{count} {count, plural, one {follower} other {followers}}",
"general feed": "general tape",
"header 1": "header 1",
"header 2": "header 2",
@ -420,6 +420,7 @@
"register": "register",
"repeat": "repeat",
"shout": "post",
"shoutsWithCount": "{count} {count, plural, one {post} other {posts}}",
"sign up or sign in": "sign up or sign in",
"slug is used by another user": "Slug is already taken by another user",
"subscriber": "subscriber",
@ -429,8 +430,10 @@
"subscription_rp": "subscription",
"subscriptions": "subscriptions",
"terms of use": "terms of use",
"today": "today",
"topics": "topics",
"user already exist": "user already exists",
"video": "video",
"view": "view"
"view": "view",
"yesterday": "yesterday"
}

View File

@ -220,22 +220,18 @@
"New only": "Только новые",
"New password": "Новый пароль",
"New stories every day and even more!": "Каждый день вас ждут новые истории и ещё много всего интересного!",
"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": "Уведомлений пока нет",
"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": "Здесь ничего нет",
"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} пользователей}}",
"Notifications": "Уведомления",
"Or paste a link to an image": "Или вставьте ссылку на изображение",
"Ordered list": "Нумерованный список",
@ -389,6 +385,7 @@
"Write about the topic": "Написать в тему",
"Write an article": "Написать статью",
"Write comment": "Написать комментарий",
"Write good articles, comment\nand it won't be so empty here": "Пишите хорошие статьи, комментируйте,\nи здесь станет не так пусто",
"Write message": "Написать сообщение",
"Write to us": "Напишите нам",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "Можно загрузить сразу несколько треков в форматах .mp3, .wav или .flac",
@ -405,6 +402,7 @@
"article": "статья",
"author": "автор",
"authors": "авторы",
"authorsWithCount": "{count} {count, plural, one {автор} few {автора} other {авторов}}",
"back to menu": "назад в меню",
"bold": "жирный",
"bookmarks": "закладки",
@ -418,10 +416,12 @@
"discourse_theme": "Тема дискурса",
"discussion": "дискурс",
"drafts": "черновики",
"earlier": "ранее",
"email not confirmed": "email не подтвержден",
"enter": "войдите",
"feed": "лента",
"follower": "подписчик",
"followersWithCount": "{count} {count, plural, one {подписчик} few {подписчика} other {подписчиков}}",
"general feed": "Общая лента",
"header 1": "заголовок 1",
"header 2": "заголовок 2",
@ -444,6 +444,7 @@
"register": "зарегистрируйтесь",
"repeat": "повторить",
"shout": "пост",
"shoutsWithCount": "{count} {count, plural, one {пост} few {поста} other {постов}}",
"sign in": "войти",
"sign up": "зарегистрироваться",
"sign up or sign in": "зарегистрироваться или войти",
@ -453,8 +454,10 @@
"subscriber_rp": "подписчика",
"subscribers": "подписчиков",
"terms of use": "правилами пользования сайтом",
"today": "сегодня",
"topics": "темы",
"user already exist": "пользователь уже существует",
"video": "видео",
"view": "просмотр"
"view": "просмотр",
"yesterday": "вчера"
}

View File

@ -1,7 +1,6 @@
import { Show } from 'solid-js'
import { Icon } from '../_shared/Icon'
import type { Reaction } from '../../graphql/types.gen'
import { formatDate } from '../../utils'
import { useLocalize } from '../../context/localize'
import { clsx } from 'clsx'
import styles from './CommentDate.module.scss'
@ -13,9 +12,9 @@ type Props = {
}
export const CommentDate = (props: Props) => {
const { t } = useLocalize()
const { t, formatDate } = useLocalize()
const formattedDate = (date) => {
const formattedDate = (date: number) => {
const formatDateOptions: Intl.DateTimeFormatOptions = props.isShort
? { month: 'long', day: 'numeric', year: 'numeric' }
: { hour: 'numeric', minute: 'numeric' }

View File

@ -8,8 +8,7 @@ import { useSession } from '../../context/session'
import { useLocalize } from '../../context/localize'
import { useReactions } from '../../context/reactions'
import { MediaItem } from '../../pages/types'
import { router, useRouter } from '../../stores/router'
import { formatDate } from '../../utils'
import { DEFAULT_HEADER_OFFSET, router, useRouter } from '../../stores/router'
import { getDescription } from '../../utils/meta'
import { imageProxy } from '../../utils/imageProxy'
import { AuthorCard } from '../Author/AuthorCard'
@ -42,14 +41,14 @@ const scrollTo = (el: HTMLElement) => {
const { top } = el.getBoundingClientRect()
window.scrollTo({
top: top + window.scrollY - 96,
top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
left: 0,
behavior: 'smooth'
})
}
export const FullArticle = (props: Props) => {
const { t } = useLocalize()
const { t, formatDate } = useLocalize()
const {
user,
isAuthenticated,

View File

@ -3,7 +3,6 @@ import styles from './AuthorBadge.module.scss'
import { Userpic } from '../Userpic'
import { Author, FollowingEntity } from '../../../graphql/types.gen'
import { createMemo, createSignal, Match, Show, Switch } from 'solid-js'
import { formatDate } from '../../../utils'
import { useLocalize } from '../../../context/localize'
import { Button } from '../../_shared/Button'
import { useSession } from '../../../context/session'
@ -21,7 +20,7 @@ export const AuthorBadge = (props: Props) => {
actions: { loadSession, requireAuthentication }
} = useSession()
const { t } = useLocalize()
const { t, formatDate } = useLocalize()
const subscribed = createMemo<boolean>(() => {
return session()?.news?.authors?.some((u) => u === props.author.slug) || false
})

View File

@ -1,8 +1,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, Match, Show, Switch } from 'solid-js'
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'
import { translit } from '../../../utils/ru2en'
import { follow, unfollow } from '../../../stores/zine/common'
import { clsx } from 'clsx'
@ -20,7 +19,7 @@ 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'
import styles from './AuthorCard.module.scss'
type Props = {
caption?: string

View File

@ -2,8 +2,6 @@ import { clsx } from 'clsx'
import styles from './Draft.module.scss'
import type { Shout } from '../../graphql/types.gen'
import { Icon } from '../_shared/Icon'
import { formatDate } from '../../utils'
import formatDateTime from '../../utils/formatDateTime'
import { useLocalize } from '../../context/localize'
import { useConfirm } from '../../context/confirm'
import { useSnackbar } from '../../context/snackbar'
@ -18,7 +16,7 @@ type Props = {
}
export const Draft = (props: Props) => {
const { t } = useLocalize()
const { t, formatDate } = useLocalize()
const {
actions: { showConfirm }
} = useConfirm()
@ -51,8 +49,8 @@ export const Draft = (props: Props) => {
return (
<div class={clsx(props.class)}>
<div class={styles.created}>
<Icon name="pencil-outline" class={styles.icon} /> {formatDate(new Date(props.shout.createdAt))}
&nbsp;{formatDateTime(props.shout.createdAt)()}
<Icon name="pencil-outline" class={styles.icon} />{' '}
{formatDate(new Date(props.shout.createdAt), { hour: '2-digit', minute: '2-digit' })}
</div>
<div class={styles.titleContainer}>
<span class={styles.title}>{props.shout.title || t('Unnamed draft')}</span> {props.shout.subtitle}

View File

@ -1,6 +1,5 @@
import { createMemo, createSignal, For, Show } from 'solid-js'
import type { Shout } from '../../graphql/types.gen'
import { capitalize, formatDate } from '../../utils'
import { Icon } from '../_shared/Icon'
import styles from './ArticleCard.module.scss'
import { clsx } from 'clsx'
@ -17,6 +16,7 @@ import { imageProxy } from '../../utils/imageProxy'
import { Popover } from '../_shared/Popover'
import { AuthorCard } from '../Author/AuthorCard'
import { useSession } from '../../context/session'
import { capitalize } from '../../utils/capitalize'
interface ArticleCardProps {
settings?: {
@ -44,7 +44,12 @@ interface ArticleCardProps {
article: Shout
}
const getTitleAndSubtitle = (article: Shout): { title: string; subtitle: string } => {
const getTitleAndSubtitle = (
article: Shout
): {
title: string
subtitle: string
} => {
let title = article.title
let subtitle = article.subtitle
@ -66,14 +71,14 @@ const getTitleAndSubtitle = (article: Shout): { title: string; subtitle: string
}
export const ArticleCard = (props: ArticleCardProps) => {
const { t, lang } = useLocalize()
const { t, lang, formatDate } = useLocalize()
const { user } = useSession()
const mainTopic =
props.article.topics.find((articleTopic) => articleTopic.slug === props.article.mainTopic) ||
props.article.topics[0]
const formattedDate = createMemo<string>(() => {
return formatDate(new Date(props.article.createdAt), { month: 'long', day: 'numeric', year: 'numeric' })
return formatDate(new Date(props.article.createdAt))
})
const { title, subtitle } = getTitleAndSubtitle(props.article)

View File

@ -2,7 +2,6 @@ import { Show, Switch, Match, createMemo } from 'solid-js'
import DialogAvatar from './DialogAvatar'
import type { ChatMember } from '../../graphql/types.gen'
import GroupDialogAvatar from './GroupDialogAvatar'
import formattedTime from '../../utils/formatDateTime'
import { clsx } from 'clsx'
import styles from './DialogCard.module.scss'
import { useLocalize } from '../../context/localize'
@ -20,7 +19,7 @@ type DialogProps = {
}
const DialogCard = (props: DialogProps) => {
const { t } = useLocalize()
const { t, formatTime } = useLocalize()
const companions = createMemo(
() => props.members && props.members.filter((member) => member.id !== props.ownId)
)
@ -64,7 +63,7 @@ const DialogCard = (props: DialogProps) => {
<Show when={!props.isChatHeader}>
<div class={styles.activity}>
<Show when={props.lastUpdate}>
<div class={styles.time}>{formattedTime(props.lastUpdate * 1000)()}</div>
<div class={styles.time}>{formatTime(new Date(props.lastUpdate * 1000))}</div>
</Show>
<Show when={props.counter > 0}>
<div class={styles.counter}>

View File

@ -3,10 +3,10 @@ import { clsx } from 'clsx'
import styles from './Message.module.scss'
import DialogAvatar from './DialogAvatar'
import type { Message as MessageType, ChatMember } from '../../graphql/types.gen'
import formattedTime from '../../utils/formatDateTime'
import { Icon } from '../_shared/Icon'
import { MessageActionsPopup } from './MessageActionsPopup'
import QuotedMessage from './QuotedMessage'
import { useLocalize } from '../../context/localize'
type Props = {
content: MessageType
@ -18,6 +18,7 @@ type Props = {
}
export const Message = (props: Props) => {
const { formatTime } = useLocalize()
const isOwn = props.ownId === Number(props.content.author)
const user = props.members?.find((m) => m.id === Number(props.content.author))
const [isPopupVisible, setIsPopupVisible] = createSignal<boolean>(false)
@ -47,7 +48,7 @@ export const Message = (props: Props) => {
<div innerHTML={props.content.body} />
</div>
</div>
<div class={styles.time}>{formattedTime(props.content.createdAt * 1000)()}</div>
<div class={styles.time}>{formatTime(new Date(props.content.createdAt * 1000))}</div>
</div>
)
}

View File

@ -36,4 +36,9 @@
.timeContainer {
margin-left: auto;
padding-left: 16px;
color: var(--black-400);
font-size: 12px;
font-weight: 500;
line-height: 16px;
align-self: flex-start;
}

View File

@ -1,5 +1,4 @@
import { clsx } from 'clsx'
import styles from './NotificationView.module.scss'
import type { Notification } from '../../../graphql/types.gen'
import { createMemo, createSignal, onMount, Show } from 'solid-js'
import { NotificationType } from '../../../graphql/types.gen'
@ -9,10 +8,13 @@ import { useNotifications } from '../../../context/notifications'
import { Userpic } from '../../Author/Userpic'
import { useLocalize } from '../../../context/localize'
import type { ArticlePageSearchParams } from '../../Article/FullArticle'
import { TimeAgo } from '../../_shared/TimeAgo'
import styles from './NotificationView.module.scss'
type Props = {
notification: Notification
onClick: () => void
dateTimeFormat: 'ago' | 'time' | 'date'
class?: string
}
@ -37,7 +39,7 @@ export const NotificationView = (props: Props) => {
const { changeSearchParam } = useRouter<ArticlePageSearchParams>()
const { t } = useLocalize()
const { t, formatDate, formatTime } = useLocalize()
const [data, setData] = createSignal<NotificationData>(null)
@ -136,6 +138,20 @@ export const NotificationView = (props: Props) => {
}
}
const formattedDateTime = createMemo(() => {
switch (props.dateTimeFormat) {
case 'ago': {
return <TimeAgo date={props.notification.createdAt} />
}
case 'time': {
return formatTime(new Date(props.notification.createdAt))
}
case 'date': {
return formatDate(new Date(props.notification.createdAt), { month: 'numeric', year: '2-digit' })
}
}
})
return (
<Show when={data()}>
<div
@ -146,9 +162,7 @@ export const NotificationView = (props: Props) => {
>
<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 class={styles.timeContainer}>{formattedDateTime()}</div>
</div>
</Show>
)

View File

@ -64,3 +64,12 @@ $transition-duration: 200ms;
.emptyMessageContainer {
text-align: center;
}
.periodTitle {
// TODO: check markup
margin: 32px 0 16px 0;
color: var(--black-400);
font-size: 12px;
font-weight: 500;
line-height: 14px;
}

View File

@ -4,7 +4,7 @@ import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler'
import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler'
import { useLocalize } from '../../context/localize'
import { Icon } from '../_shared/Icon'
import { createEffect, For } from 'solid-js'
import { createEffect, createMemo, For, Show } from 'solid-js'
import { useNotifications } from '../../context/notifications'
import { NotificationView } from './NotificationView'
import { EmptyMessage } from './EmptyMessage'
@ -14,6 +14,30 @@ type Props = {
onClose: () => void
}
const getYesterdayStart = () => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0)
}
const isSameDate = (date1: Date, date2: Date) =>
date1.getDate() === date2.getDate() &&
date1.getMonth() === date2.getMonth() &&
date1.getFullYear() === date2.getFullYear()
const isToday = (date: Date) => {
return isSameDate(date, new Date())
}
const isYesterday = (date: Date) => {
const yesterday = getYesterdayStart()
return isSameDate(date, yesterday)
}
const isEarlier = (date: Date) => {
const yesterday = getYesterdayStart()
return date.getTime() < yesterday.getTime()
}
export const NotificationsPanel = (props: Props) => {
const { t } = useLocalize()
const { sortedNotifications } = useNotifications()
@ -55,6 +79,18 @@ export const NotificationsPanel = (props: Props) => {
handleHide()
}
const todayNotifications = createMemo(() => {
return sortedNotifications().filter((notification) => isToday(new Date(notification.createdAt)))
})
const yesterdayNotifications = createMemo(() => {
return sortedNotifications().filter((notification) => isYesterday(new Date(notification.createdAt)))
})
const earlierNotifications = createMemo(() => {
return sortedNotifications().filter((notification) => isEarlier(new Date(notification.createdAt)))
})
return (
<div
class={clsx(styles.container, {
@ -67,15 +103,47 @@ export const NotificationsPanel = (props: Props) => {
<Icon name="close" />
</div>
<div class={styles.title}>{t('Notifications')}</div>
<For each={sortedNotifications()} fallback={<EmptyMessage />}>
{(notification) => (
<NotificationView
notification={notification}
class={styles.notificationView}
onClick={handleNotificationViewClick}
/>
)}
</For>
<Show when={sortedNotifications().length > 0} fallback={<EmptyMessage />}>
<Show when={todayNotifications().length > 0}>
<div class={styles.periodTitle}>{t('today')}</div>
<For each={todayNotifications()}>
{(notification) => (
<NotificationView
notification={notification}
class={styles.notificationView}
onClick={handleNotificationViewClick}
dateTimeFormat={'ago'}
/>
)}
</For>
</Show>
<Show when={yesterdayNotifications().length > 0}>
<div class={styles.periodTitle}>{t('yesterday')}</div>
<For each={yesterdayNotifications()}>
{(notification) => (
<NotificationView
notification={notification}
class={styles.notificationView}
onClick={handleNotificationViewClick}
dateTimeFormat={'time'}
/>
)}
</For>
</Show>
<Show when={earlierNotifications().length > 0}>
<div class={styles.periodTitle}>{t('earlier')}</div>
<For each={earlierNotifications()}>
{(notification) => (
<NotificationView
notification={notification}
class={styles.notificationView}
onClick={handleNotificationViewClick}
dateTimeFormat={'date'}
/>
)}
</For>
</Show>
</Show>
</div>
</div>
)

View File

@ -1,5 +1,3 @@
import { capitalize } from '../../utils'
import styles from './Card.module.scss'
import { createMemo, createSignal, Show } from 'solid-js'
import type { Topic } from '../../graphql/types.gen'
import { FollowingEntity } from '../../graphql/types.gen'
@ -12,6 +10,9 @@ import { Icon } from '../_shared/Icon'
import { useLocalize } from '../../context/localize'
import { CardTopic } from '../Feed/CardTopic'
import { CheckButton } from '../_shared/CheckButton'
import { capitalize } from '../../utils/capitalize'
import styles from './Card.module.scss'
interface TopicProps {
topic: Topic
@ -109,14 +110,6 @@ export const TopicCard = (props: TopicProps) => {
{props.topic.body}
</div>
</Show>
<Show when={props.showDescription && !props.topic?.body && props.topic.stat?.shouts > 0}>
<div
class={clsx(styles.topicDescription)}
classList={{ [styles.topicDescriptionShort]: props.shortDescription }}
>
{props.topic.stat?.shouts} публикаций
</div>
</Show>
</div>
<div
class={styles.controlContainer}

View File

@ -1,4 +1,4 @@
.allTopicsPage {
.allAuthorsPage {
.group {
@include font-size(1.6rem);
@ -32,10 +32,6 @@
}
}
.stats {
margin-top: 2.4rem;
}
.loadMoreContainer {
margin-top: 48px;
text-align: center;
@ -52,6 +48,7 @@
.alphabet {
@include font-size(1.5rem);
color: rgba(0 0 0 / 20%);
display: flex;
flex-wrap: wrap;
@ -71,6 +68,7 @@
.articlesCounter {
@include font-size(1.2rem);
margin-left: 0.5em;
vertical-align: super;
}

View File

@ -6,12 +6,13 @@ import { useRouter } from '../../stores/router'
import { AuthorCard } from '../Author/AuthorCard'
import { clsx } from 'clsx'
import { useSession } from '../../context/session'
import styles from '../../styles/AllTopics.module.scss'
import { SearchField } from '../_shared/SearchField'
import { scrollHandler } from '../../utils/scroll'
import { useLocalize } from '../../context/localize'
import { dummyFilter } from '../../utils/dummyFilter'
import styles from './AllAuthors.module.scss'
type AllAuthorsPageSearchParams = {
by: '' | 'name' | 'shouts' | 'followers'
}
@ -109,7 +110,7 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
)
return (
<div class={clsx(styles.allTopicsPage, 'wide-container')}>
<div class={clsx(styles.allAuthorsPage, 'wide-container')}>
<Show when={sortedAuthors().length > 0}>
<div class="offset-md-5">
<AllAuthorsHead />

View File

@ -0,0 +1,115 @@
.allTopicsPage {
.group {
@include font-size(1.6rem);
margin: 3em 0 9.6rem;
@include media-breakpoint-down(sm) {
margin-bottom: 6.4rem;
}
h2 {
margin-bottom: 3.2rem;
text-transform: capitalize;
@include media-breakpoint-down(sm) {
margin-bottom: 1.6rem;
}
}
.topic {
margin-bottom: 2.4rem;
}
}
.container {
width: auto;
.search-input {
display: inline-block;
width: 100px !important;
}
}
}
.stats {
@include font-size(1.7rem);
color: #9fa1a7;
display: flex;
margin: 0 0 1em;
@include media-breakpoint-down(md) {
flex-wrap: wrap;
}
@include media-breakpoint-down(sm) {
margin-top: 0.5em;
}
.statsItem {
@include font-size(1.5rem);
margin-right: 1.6rem;
white-space: nowrap;
&:last-child {
margin-right: 0;
}
&.compact {
font-size: small;
}
&.followers {
word-break: keep-all;
}
&.button {
float: right;
}
}
}
.loadMoreContainer {
margin-top: 48px;
text-align: center;
.loadMoreButton {
padding: 0.6em 3em;
width: 100%;
@include media-breakpoint-up(sm) {
width: auto;
}
}
}
.alphabet {
@include font-size(1.5rem);
color: rgba(0 0 0 / 20%);
display: flex;
flex-wrap: wrap;
font-weight: 700;
margin: 1.5em -3% 0 0;
li {
min-width: 1.5em;
margin-right: 3%;
color: rgb(0 0 0 / 30%);
}
a {
border: none;
}
}
.articlesCounter {
@include font-size(1.2rem);
margin-left: 0.5em;
vertical-align: super;
}
.viewSwitcher {
margin-bottom: 2rem;
}

View File

@ -6,13 +6,13 @@ import { useRouter } from '../../stores/router'
import { TopicCard } from '../Topic/Card'
import { clsx } from 'clsx'
import { useSession } from '../../context/session'
import styles from '../../styles/AllTopics.module.scss'
import { SearchField } from '../_shared/SearchField'
import { scrollHandler } from '../../utils/scroll'
import { StatMetrics } from '../_shared/StatMetrics'
import { useLocalize } from '../../context/localize'
import { dummyFilter } from '../../utils/dummyFilter'
import styles from './AllTopics.module.scss'
type AllTopicsPageSearchParams = {
by: 'shouts' | 'authors' | 'title' | ''
}
@ -168,7 +168,17 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
showPublications={true}
showDescription={true}
/>
<StatMetrics fields={['shouts', 'authors', 'followers']} stat={topic.stat} />
<div class={styles.stats}>
<span class={styles.statsItem}>
{t('shoutsWithCount', { count: topic.stat.shouts })}
</span>
<span class={styles.statsItem}>
{t('authorsWithCount', { count: topic.stat.authors })}
</span>
<span class={styles.statsItem}>
{t('followersWithCount', { count: topic.stat.followers })}
</span>
</div>
</>
)}
</For>

View File

@ -1,36 +0,0 @@
.statMetrics {
@include font-size(1.7rem);
color: #9fa1a7;
display: flex;
margin: 0 0 1em;
@include media-breakpoint-down(md) {
flex-wrap: wrap;
}
@include media-breakpoint-down(sm) {
margin-top: 0.5em;
}
}
.statMetricsItem {
@include font-size(1.5rem);
margin-right: 1.6rem;
white-space: nowrap;
&:last-child {
margin-right: 0;
}
&.compact {
font-size: small;
}
&.followers {
word-break: keep-all;
}
&.button {
float: right;
}
}

View File

@ -1,45 +0,0 @@
import { For } from 'solid-js'
import type { Stat, TopicStat } from '../../graphql/types.gen'
import { plural } from '../../utils'
import styles from './Stat.module.scss'
import { useLocalize } from '../../context/localize'
interface StatMetricsProps {
fields?: string[]
stat: Stat | TopicStat
compact?: boolean
}
const pseudonames = {
// topics: 'topics' # amount of topics for community💥
followed: 'follower',
followers: 'follower',
rating: 'like',
viewed: 'view',
views: 'view',
reacted: 'involving',
reactions: 'involving',
commented: 'discussion',
comments: 'discussion',
shouts: 'post',
authors: 'author'
}
export const StatMetrics = (props: StatMetricsProps) => {
const { t, lang } = useLocalize()
return (
<div class={styles.statMetrics}>
<For each={props.fields}>
{(entity: string) => (
<span class={styles.statMetricsItem} classList={{ compact: props.compact }}>
{props.stat[entity] +
' ' +
t(pseudonames[entity] || entity.slice(-1)) +
plural(props.stat[entity] || 0, lang() === 'ru' ? ['ов', '', 'а'] : ['s', '', 's'])}
</span>
)}
</For>
</div>
)
}

View File

@ -0,0 +1,3 @@
.TimeAgo {
white-space: nowrap;
}

View File

@ -0,0 +1,37 @@
import { clsx } from 'clsx'
import { useLocalize } from '../../../context/localize'
import { createSignal, onCleanup, onMount } from 'solid-js'
import styles from './TimeAgo.module.scss'
type Props = {
date: any
class?: string
}
export const TimeAgo = (props: Props) => {
const { formatDate, formatTimeAgo } = useLocalize()
const [formattedTimeAgo, setFormattedTimeAgo] = createSignal(formatTimeAgo(new Date(props.date)))
onMount(() => {
let timerId: NodeJS.Timeout
const updateTimeAgo = () => {
timerId = setTimeout(() => {
setFormattedTimeAgo(formatTimeAgo(new Date(props.date)))
updateTimeAgo()
}, 1000)
}
updateTimeAgo()
onCleanup(() => clearTimeout(timerId))
})
return (
<div
class={clsx(styles.TimeAgo, props.class)}
title={formatDate(new Date(props.date), { month: '2-digit', hour: '2-digit', minute: '2-digit' })}
>
{formattedTimeAgo()}
</div>
)
}

View File

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

View File

@ -1,14 +1,23 @@
import type { i18n } from 'i18next'
import type { Accessor, JSX } from 'solid-js'
import { createContext, createEffect, createSignal, Show, useContext } from 'solid-js'
import { createContext, createEffect, createMemo, createSignal, Show, useContext } from 'solid-js'
import { useRouter } from '../stores/router'
import i18next, { changeLanguage, t } from 'i18next'
import Cookie from 'js-cookie'
import TimeAgo from 'javascript-time-ago'
import en from 'javascript-time-ago/locale/en'
import ru from 'javascript-time-ago/locale/ru'
TimeAgo.addLocale(en)
TimeAgo.addLocale(ru)
type LocalizeContextType = {
t: i18n['t']
lang: Accessor<Language>
setLang: (lang: Language) => void
formatTime: (date: Date, options?: Intl.DateTimeFormatOptions) => string
formatDate: (date: Date, options?: Intl.DateTimeFormatOptions) => string
formatTimeAgo: (date: Date) => string
}
export type Language = 'ru' | 'en'
@ -21,7 +30,9 @@ export function useLocalize() {
export const LocalizeProvider = (props: { children: JSX.Element }) => {
const [lang, setLang] = createSignal<Language>(i18next.language === 'en' ? 'en' : 'ru')
const { searchParams, changeSearchParam } = useRouter<{ lng: string }>()
const { searchParams, changeSearchParam } = useRouter<{
lng: string
}>()
createEffect(() => {
if (!searchParams().lng) {
@ -36,7 +47,43 @@ export const LocalizeProvider = (props: { children: JSX.Element }) => {
changeSearchParam({ lng: null }, true)
})
const value: LocalizeContextType = { t, lang, setLang }
const formatTime = (date: Date, options: Intl.DateTimeFormatOptions = {}) => {
const opts = Object.assign(
{},
{
hour: '2-digit',
minute: '2-digit'
},
options
)
return date.toLocaleTimeString(lang(), opts)
}
const formatDate = (date: Date, options: Intl.DateTimeFormatOptions = {}) => {
const opts = Object.assign(
{},
{
month: 'long',
day: 'numeric',
year: 'numeric'
},
options
)
let result = date.toLocaleDateString(lang(), opts)
if (lang() === 'ru') {
result = result.replace(' г.', '')
}
return result
}
const timeAgo = createMemo(() => new TimeAgo(lang()))
const formatTimeAgo = (date: Date) => timeAgo().format(date)
const value: LocalizeContextType = { t, lang, setLang, formatTime, formatDate, formatTimeAgo }
return (
<LocalizeContext.Provider value={value}>

View File

@ -4,10 +4,11 @@ import { App } from '../components/App'
import { initRouter } from '../stores/router'
import type { PageContext } from './types'
import { MetaProvider, renderTags } from '@solidjs/meta'
import i18next, { changeLanguage, init as initI18next } from 'i18next'
import i18next from 'i18next'
import ru from '../../public/locales/ru/translation.json'
import en from '../../public/locales/en/translation.json'
import type { Language } from '../context/localize'
import ICU from 'i18next-icu'
export const passToClient = ['pageProps', 'lng', 'documentProps', 'is404']
@ -32,7 +33,7 @@ export const render = async (pageContext: PageContext) => {
if (!i18next.isInitialized) {
// eslint-disable-next-line import/no-named-as-default-member
await initI18next({
await i18next.use(ICU).init({
// debug: true,
supportedLngs: ['ru', 'en'],
fallbackLng: lng,
@ -44,7 +45,7 @@ export const render = async (pageContext: PageContext) => {
}
})
} else if (i18next.language !== lng) {
await changeLanguage(lng)
await i18next.changeLanguage(lng)
}
if (pageContext.is404) {

View File

@ -355,7 +355,7 @@ export const apiClient = {
},
getNotifications: async (params: NotificationsQueryParams): Promise<NotificationsQueryResult> => {
const resp = await privateGraphQLClient.query(notifications, params).toPromise()
console.debug(resp.data)
// console.debug(resp.data)
return resp.data.loadNotifications
},
markNotificationAsRead: async (notificationId: number): Promise<void> => {

9
src/utils/capitalize.ts Normal file
View File

@ -0,0 +1,9 @@
export const capitalize = (originalString: string, firstonly = false) => {
const s = originalString.trim()
return firstonly
? s.charAt(0).toUpperCase() + s.slice(1)
: s
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}

View File

@ -1,7 +1,6 @@
import { translit } from './ru2en'
import { Author, Topic } from '../graphql/types.gen'
type SearchData = Array<Author | Topic>
import { isAuthor } from './isAuthor'
const prepareQuery = (searchQuery, lang) => {
const q = searchQuery.toLowerCase()
@ -14,9 +13,16 @@ const stringMatches = (str, q, lang) => {
return preparedStr.split(' ').some((word) => word.startsWith(q))
}
export const dummyFilter = (data: SearchData, searchQuery: string, lang: 'ru' | 'en'): SearchData => {
export const dummyFilter = <T extends Topic | Author>(
data: T[],
searchQuery: string,
lang: 'ru' | 'en'
): T[] => {
const q = prepareQuery(searchQuery, lang)
if (q.length === 0) return data
if (q.length === 0) {
return data
}
return data.filter((item) => {
const slugMatches = item.slug && item.slug.split('-').some((w) => w.startsWith(q))
@ -26,9 +32,10 @@ export const dummyFilter = (data: SearchData, searchQuery: string, lang: 'ru' |
return stringMatches(item.title, q, lang)
}
if ('name' in item) {
if (isAuthor(item)) {
return stringMatches(item.name, q, lang) || (item.bio && stringMatches(item.bio, q, lang))
}
// If it does not match any of the 'slug', 'title', 'name' , 'bio' fields
// current element should not be included in the filtered array
return false

View File

@ -1,17 +0,0 @@
import { Accessor, createMemo } from 'solid-js'
import { useLocalize } from '../context/localize'
// unix timestamp in seconds
const formattedTime = (time: number): Accessor<string> => {
// FIXME: maybe it's better to move it from here
const { lang } = useLocalize()
return createMemo<string>(() => {
return new Date(time).toLocaleTimeString(lang(), {
hour: 'numeric',
minute: 'numeric'
})
})
}
export default formattedTime

View File

@ -1,83 +0,0 @@
export const reflow = () => document.body.clientWidth
export const unique = (v) => {
const s = new Set(v)
return [...s]
}
export const preventSmoothScrollOnTabbing = () => {
document.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return
document.documentElement.style.scrollBehavior = ''
setTimeout(() => {
document.documentElement.style.scrollBehavior = 'smooth'
})
})
}
export const capitalize = (originalString: string, firstonly = false) => {
const s = originalString.trim()
return firstonly
? s.charAt(0).toUpperCase() + s.slice(1)
: s
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
export const plural = (amount: number, w: string[]) => {
try {
const a = amount.toString()
const x = Number.parseInt(a.at(-1))
const xx = Number.parseInt(a.at(-2) + a.at(-1))
if (xx > 5 && xx < 20) return w[0]
if (x === 1) return w[1]
if (x > 1 && x < 5) return w[2]
} catch (error) {
console.error('[utils] plural error', error)
}
return w[0]
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const shuffle = (items: any[]) => {
const cached = [...items]
let temp
let i = cached.length
let rand
while (--i) {
rand = Math.floor(i * Math.random())
temp = cached[rand]
cached[rand] = cached[i]
cached[i] = temp
}
return cached
}
export const snake2camel = (s: string) =>
s
.split(/(?=[A-Z])/)
.join('-')
.toLowerCase()
export const formatDate = (date: Date, options: Intl.DateTimeFormatOptions = {}) => {
const opts = Object.assign(
{},
{
month: 'long',
day: 'numeric',
year: 'numeric'
},
options
)
return date.toLocaleDateString('ru', opts).replace(' г.', '')
}